mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-02 21:42:44 +00:00
Compare commits
14 Commits
jamison/me
...
cli-agent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5970f8f7f | ||
|
|
0eaab180dd | ||
|
|
9399cc7548 | ||
|
|
9c3a85d1fc | ||
|
|
2faa475c83 | ||
|
|
52d926f002 | ||
|
|
718227a336 | ||
|
|
73cd88a708 | ||
|
|
b08f50fa53 | ||
|
|
ea8366aa69 | ||
|
|
e6f7c2b45c | ||
|
|
f77128d929 | ||
|
|
1d4ca769e7 | ||
|
|
e002f6c195 |
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -16,16 +17,23 @@ func newAgentsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agents",
|
||||
Short: "List available agents",
|
||||
Long: `List all visible agents configured on the Onyx server.
|
||||
|
||||
By default, output is a human-readable table with ID, name, and description.
|
||||
Use --json for machine-readable output.`,
|
||||
Example: ` onyx-cli agents
|
||||
onyx-cli agents --json
|
||||
onyx-cli agents --json | jq '.[].name'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := config.Load()
|
||||
if !cfg.IsConfigured() {
|
||||
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
|
||||
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
|
||||
}
|
||||
|
||||
client := api.NewClient(cfg)
|
||||
agents, err := client.ListAgents(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list agents: %w", err)
|
||||
return fmt.Errorf("failed to list agents: %w\n Check your connection with: onyx-cli validate-config", err)
|
||||
}
|
||||
|
||||
if agentsJSON {
|
||||
|
||||
195
cli/cmd/ask.go
195
cli/cmd/ask.go
@@ -4,33 +4,64 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/models"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const defaultMaxOutputBytes = 4096
|
||||
|
||||
func newAskCmd() *cobra.Command {
|
||||
var (
|
||||
askAgentID int
|
||||
askJSON bool
|
||||
askQuiet bool
|
||||
askPrompt string
|
||||
maxOutput int
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ask [question]",
|
||||
Short: "Ask a one-shot question (non-interactive)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: `Send a one-shot question to an Onyx agent and print the response.
|
||||
|
||||
The question can be provided as a positional argument, via --prompt, or piped
|
||||
through stdin. When stdin contains piped data, it is sent as context along
|
||||
with the question from --prompt (or used as the question itself).
|
||||
|
||||
When stdout is not a TTY (e.g., called by a script or AI agent), output is
|
||||
automatically truncated to --max-output bytes and the full response is saved
|
||||
to a temp file. Set --max-output 0 to disable truncation.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Example: ` onyx-cli ask "What connectors are available?"
|
||||
onyx-cli ask --agent-id 3 "Summarize our Q4 revenue"
|
||||
onyx-cli ask --json "List all users" | jq '.event.content'
|
||||
cat error.log | onyx-cli ask --prompt "Find the root cause"
|
||||
echo "what is onyx?" | onyx-cli ask`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := config.Load()
|
||||
if !cfg.IsConfigured() {
|
||||
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
|
||||
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
|
||||
}
|
||||
|
||||
if askJSON && askQuiet {
|
||||
return exitcodes.New(exitcodes.BadRequest, "--json and --quiet cannot be used together")
|
||||
}
|
||||
|
||||
question, err := resolveQuestion(args, askPrompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
question := args[0]
|
||||
agentID := cfg.DefaultAgentID
|
||||
if cmd.Flags().Changed("agent-id") {
|
||||
agentID = askAgentID
|
||||
@@ -50,9 +81,22 @@ func newAskCmd() *cobra.Command {
|
||||
nil,
|
||||
)
|
||||
|
||||
// Determine truncation threshold.
|
||||
truncateAt := 0 // 0 means no truncation
|
||||
if cmd.Flags().Changed("max-output") {
|
||||
truncateAt = maxOutput
|
||||
} else if !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
truncateAt = defaultMaxOutputBytes
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
var lastErr error
|
||||
gotStop := false
|
||||
|
||||
// Overflow writer: tees to stdout and optionally to a temp file.
|
||||
// In quiet mode, buffer everything and print once at the end.
|
||||
ow := &overflowWriter{limit: truncateAt, quiet: askQuiet}
|
||||
|
||||
for event := range ch {
|
||||
if e, ok := event.(models.SessionCreatedEvent); ok {
|
||||
sessionID = e.ChatSessionID
|
||||
@@ -82,22 +126,22 @@ func newAskCmd() *cobra.Command {
|
||||
|
||||
switch e := event.(type) {
|
||||
case models.MessageDeltaEvent:
|
||||
fmt.Print(e.Content)
|
||||
ow.Write(e.Content)
|
||||
case models.ErrorEvent:
|
||||
ow.Finish()
|
||||
return fmt.Errorf("%s", e.Error)
|
||||
case models.StopEvent:
|
||||
fmt.Println()
|
||||
ow.Finish()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ow.Finish()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
if sessionID != "" {
|
||||
client.StopChatSession(context.Background(), sessionID)
|
||||
}
|
||||
if !askJSON {
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -105,20 +149,141 @@ func newAskCmd() *cobra.Command {
|
||||
return lastErr
|
||||
}
|
||||
if !gotStop {
|
||||
if !askJSON {
|
||||
fmt.Println()
|
||||
}
|
||||
return fmt.Errorf("stream ended unexpectedly")
|
||||
}
|
||||
if !askJSON {
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&askAgentID, "agent-id", 0, "Agent ID to use")
|
||||
cmd.Flags().BoolVar(&askJSON, "json", false, "Output raw JSON events")
|
||||
// Suppress cobra's default error/usage on RunE errors
|
||||
cmd.Flags().BoolVarP(&askQuiet, "quiet", "q", false, "Buffer output and print once at end (no streaming)")
|
||||
cmd.Flags().StringVar(&askPrompt, "prompt", "", "Question text (use with piped stdin context)")
|
||||
cmd.Flags().IntVar(&maxOutput, "max-output", defaultMaxOutputBytes,
|
||||
"Max bytes to print before truncating (0 to disable, auto-enabled for non-TTY)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// resolveQuestion builds the final question string from args, --prompt, and stdin.
|
||||
func resolveQuestion(args []string, prompt string) (string, error) {
|
||||
hasArg := len(args) > 0
|
||||
hasPrompt := prompt != ""
|
||||
hasStdin := !term.IsTerminal(int(os.Stdin.Fd()))
|
||||
|
||||
if hasArg && hasPrompt {
|
||||
return "", exitcodes.New(exitcodes.BadRequest, "specify the question as an argument or --prompt, not both")
|
||||
}
|
||||
|
||||
var stdinContent string
|
||||
if hasStdin {
|
||||
const maxStdinBytes = 10 * 1024 * 1024 // 10MB
|
||||
data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read stdin: %w", err)
|
||||
}
|
||||
stdinContent = strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasArg && stdinContent != "":
|
||||
// arg is the question, stdin is context
|
||||
return args[0] + "\n\n" + stdinContent, nil
|
||||
case hasArg:
|
||||
return args[0], nil
|
||||
case hasPrompt && stdinContent != "":
|
||||
// --prompt is the question, stdin is context
|
||||
return prompt + "\n\n" + stdinContent, nil
|
||||
case hasPrompt:
|
||||
return prompt, nil
|
||||
case stdinContent != "":
|
||||
return stdinContent, nil
|
||||
default:
|
||||
return "", exitcodes.New(exitcodes.BadRequest, "no question provided\n Usage: onyx-cli ask \"your question\"\n Or: echo \"context\" | onyx-cli ask --prompt \"your question\"")
|
||||
}
|
||||
}
|
||||
|
||||
// overflowWriter handles streaming output with optional truncation.
|
||||
// When limit > 0, it streams to a temp file on disk (not memory) and stops
|
||||
// writing to stdout after limit bytes. When limit == 0, it writes directly
|
||||
// to stdout. In quiet mode, it buffers in memory and prints once at the end.
|
||||
type overflowWriter struct {
|
||||
limit int
|
||||
quiet bool
|
||||
written int
|
||||
totalBytes int
|
||||
truncated bool
|
||||
buf strings.Builder // used only in quiet mode
|
||||
tmpFile *os.File // used only in truncation mode (limit > 0)
|
||||
}
|
||||
|
||||
func (w *overflowWriter) Write(s string) {
|
||||
w.totalBytes += len(s)
|
||||
|
||||
// Quiet mode: buffer in memory, print nothing
|
||||
if w.quiet {
|
||||
w.buf.WriteString(s)
|
||||
return
|
||||
}
|
||||
|
||||
if w.limit <= 0 {
|
||||
fmt.Print(s)
|
||||
return
|
||||
}
|
||||
|
||||
// Truncation mode: stream all content to temp file on disk
|
||||
if w.tmpFile == nil {
|
||||
f, err := os.CreateTemp("", "onyx-ask-*.txt")
|
||||
if err != nil {
|
||||
// Fall back to no-truncation if we can't create the file
|
||||
fmt.Fprintf(os.Stderr, "warning: could not create temp file: %v\n", err)
|
||||
w.limit = 0
|
||||
fmt.Print(s)
|
||||
return
|
||||
}
|
||||
w.tmpFile = f
|
||||
}
|
||||
_, _ = w.tmpFile.WriteString(s)
|
||||
|
||||
if w.truncated {
|
||||
return
|
||||
}
|
||||
|
||||
remaining := w.limit - w.written
|
||||
if len(s) <= remaining {
|
||||
fmt.Print(s)
|
||||
w.written += len(s)
|
||||
} else {
|
||||
if remaining > 0 {
|
||||
fmt.Print(s[:remaining])
|
||||
w.written += remaining
|
||||
}
|
||||
w.truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
func (w *overflowWriter) Finish() {
|
||||
// Quiet mode: print buffered content at once
|
||||
if w.quiet {
|
||||
fmt.Println(w.buf.String())
|
||||
return
|
||||
}
|
||||
|
||||
if !w.truncated {
|
||||
if w.tmpFile != nil {
|
||||
_ = w.tmpFile.Close()
|
||||
_ = os.Remove(w.tmpFile.Name())
|
||||
}
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
|
||||
// Close the temp file so it's readable
|
||||
tmpPath := w.tmpFile.Name()
|
||||
_ = w.tmpFile.Close()
|
||||
|
||||
fmt.Printf("\n\n--- response truncated (%d bytes total) ---\n", w.totalBytes)
|
||||
fmt.Printf("Full response: %s\n", tmpPath)
|
||||
fmt.Printf("Explore:\n")
|
||||
fmt.Printf(" cat %s | grep \"<pattern>\"\n", tmpPath)
|
||||
fmt.Printf(" cat %s | tail -50\n", tmpPath)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/starprompt"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -12,6 +13,11 @@ func newChatCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "chat",
|
||||
Short: "Launch the interactive chat TUI (default)",
|
||||
Long: `Launch the interactive terminal UI for chatting with your Onyx agent.
|
||||
This is the default command when no subcommand is specified. On first run,
|
||||
an interactive setup wizard will guide you through configuration.`,
|
||||
Example: ` onyx-cli chat
|
||||
onyx-cli`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := config.Load()
|
||||
|
||||
@@ -24,6 +30,8 @@ func newChatCmd() *cobra.Command {
|
||||
cfg = *result
|
||||
}
|
||||
|
||||
starprompt.MaybePrompt()
|
||||
|
||||
m := tui.NewModel(cfg)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
_, err := p.Run()
|
||||
|
||||
@@ -1,19 +1,98 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newConfigureCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var (
|
||||
serverURL string
|
||||
apiKey string
|
||||
dryRun bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "configure",
|
||||
Short: "Configure server URL and API key",
|
||||
Long: `Set up the Onyx CLI with your server URL and API key.
|
||||
|
||||
When --server-url and --api-key are both provided, the configuration is saved
|
||||
non-interactively (useful for scripts and AI agents). Otherwise, an interactive
|
||||
setup wizard is launched.
|
||||
|
||||
Use --dry-run with --server-url and --api-key to test the connection without
|
||||
saving the configuration.`,
|
||||
Example: ` onyx-cli configure
|
||||
onyx-cli configure --server-url https://my-onyx.com --api-key sk-...
|
||||
onyx-cli configure --server-url https://my-onyx.com --api-key sk-... --dry-run`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if serverURL != "" && apiKey != "" {
|
||||
return configureNonInteractive(serverURL, apiKey, dryRun)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return exitcodes.New(exitcodes.BadRequest, "--dry-run requires --server-url and --api-key")
|
||||
}
|
||||
|
||||
if serverURL != "" || apiKey != "" {
|
||||
return exitcodes.New(exitcodes.BadRequest, "both --server-url and --api-key are required for non-interactive setup\n Run 'onyx-cli configure' without flags for interactive setup")
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
onboarding.Run(&cfg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&serverURL, "server-url", "", "Onyx server URL (e.g., https://cloud.onyx.app)")
|
||||
cmd.Flags().StringVar(&apiKey, "api-key", "", "API key for authentication")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Test connection without saving config (requires --server-url and --api-key)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configureNonInteractive(serverURL, apiKey string, dryRun bool) error {
|
||||
cfg := config.OnyxCliConfig{
|
||||
ServerURL: serverURL,
|
||||
APIKey: apiKey,
|
||||
DefaultAgentID: 0,
|
||||
}
|
||||
|
||||
// Preserve existing default agent ID from disk (not env overrides)
|
||||
if existing := config.LoadFromDisk(); existing.DefaultAgentID != 0 {
|
||||
cfg.DefaultAgentID = existing.DefaultAgentID
|
||||
}
|
||||
|
||||
// Test connection
|
||||
client := api.NewClient(cfg)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.TestConnection(ctx); err != nil {
|
||||
return exitcodes.Newf(exitcodes.Unreachable, "connection test failed: %v\n Check your server URL and API key", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("Server: %s\n", serverURL)
|
||||
fmt.Println("Status: connected and authenticated")
|
||||
fmt.Println("Dry run: config was NOT saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return fmt.Errorf("could not save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Config: %s\n", config.ConfigFilePath())
|
||||
fmt.Printf("Server: %s\n", serverURL)
|
||||
fmt.Println("Status: connected and authenticated")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/charmbracelet/wish/ratelimiter"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/tui"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/time/rate"
|
||||
@@ -295,15 +296,15 @@ provided via the ONYX_API_KEY environment variable to skip the prompt:
|
||||
The server URL is taken from the server operator's config. The server
|
||||
auto-generates an Ed25519 host key on first run if the key file does not
|
||||
already exist. The host key path can also be set via the ONYX_SSH_HOST_KEY
|
||||
environment variable (the --host-key flag takes precedence).
|
||||
|
||||
Example:
|
||||
onyx-cli serve --port 2222
|
||||
ssh localhost -p 2222`,
|
||||
environment variable (the --host-key flag takes precedence).`,
|
||||
Example: ` onyx-cli serve --port 2222
|
||||
ssh localhost -p 2222
|
||||
onyx-cli serve --host 0.0.0.0 --port 2222
|
||||
onyx-cli serve --idle-timeout 30m --max-session-timeout 2h`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
serverCfg := config.Load()
|
||||
if serverCfg.ServerURL == "" {
|
||||
return fmt.Errorf("server URL is not configured; run 'onyx-cli configure' first")
|
||||
return exitcodes.New(exitcodes.NotConfigured, "server URL is not configured\n Run: onyx-cli configure")
|
||||
}
|
||||
if !cmd.Flags().Changed("host-key") {
|
||||
if v := os.Getenv(config.EnvSSHHostKey); v != "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/api"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/version"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -16,17 +17,21 @@ func newValidateConfigCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate-config",
|
||||
Short: "Validate configuration and test server connection",
|
||||
Long: `Check that the CLI is configured, the server is reachable, and the API key
|
||||
is valid. Also reports the server version and warns if it is below the
|
||||
minimum required.`,
|
||||
Example: ` onyx-cli validate-config`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Check config file
|
||||
if !config.ConfigExists() {
|
||||
return fmt.Errorf("config file not found at %s\n Run 'onyx-cli configure' to set up", config.ConfigFilePath())
|
||||
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", config.ConfigFilePath())
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
// Check API key
|
||||
if !cfg.IsConfigured() {
|
||||
return fmt.Errorf("API key is missing\n Run 'onyx-cli configure' to set up")
|
||||
return exitcodes.New(exitcodes.NotConfigured, "API key is missing\n Run: onyx-cli configure")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", config.ConfigFilePath())
|
||||
@@ -35,7 +40,7 @@ func newValidateConfigCmd() *cobra.Command {
|
||||
// Test connection
|
||||
client := api.NewClient(cfg)
|
||||
if err := client.TestConnection(cmd.Context()); err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
return exitcodes.Newf(exitcodes.Unreachable, "connection failed: %v\n Reconfigure with: onyx-cli configure", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Status: connected and authenticated")
|
||||
|
||||
@@ -59,8 +59,10 @@ func ConfigExists() bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Load reads config from file and applies environment variable overrides.
|
||||
func Load() OnyxCliConfig {
|
||||
// LoadFromDisk reads config from the file only, without applying environment
|
||||
// variable overrides. Use this when you need the persisted config values
|
||||
// (e.g., to preserve them during a save operation).
|
||||
func LoadFromDisk() OnyxCliConfig {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
data, err := os.ReadFile(ConfigFilePath())
|
||||
@@ -70,6 +72,13 @@ func Load() OnyxCliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Load reads config from file and applies environment variable overrides.
|
||||
func Load() OnyxCliConfig {
|
||||
cfg := LoadFromDisk()
|
||||
|
||||
// Environment overrides
|
||||
if v := os.Getenv(EnvServerURL); v != "" {
|
||||
cfg.ServerURL = v
|
||||
|
||||
42
cli/internal/exitcodes/codes.go
Normal file
42
cli/internal/exitcodes/codes.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Package exitcodes defines semantic exit codes for the Onyx CLI.
|
||||
package exitcodes
|
||||
|
||||
import "fmt"
|
||||
|
||||
const (
|
||||
Success = 0
|
||||
General = 1
|
||||
NotConfigured = 2
|
||||
AuthFailure = 3
|
||||
Unreachable = 4
|
||||
BadRequest = 5
|
||||
)
|
||||
|
||||
// ExitError wraps an error with a specific exit code.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *ExitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// New creates an ExitError with the given code and message.
|
||||
func New(code int, msg string) *ExitError {
|
||||
return &ExitError{Code: code, Err: fmt.Errorf("%s", msg)}
|
||||
}
|
||||
|
||||
// Wrap creates an ExitError wrapping an existing error.
|
||||
func Wrap(code int, err error) *ExitError {
|
||||
return &ExitError{Code: code, Err: err}
|
||||
}
|
||||
|
||||
// Newf creates an ExitError with a formatted message.
|
||||
func Newf(code int, format string, args ...any) *ExitError {
|
||||
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
|
||||
}
|
||||
48
cli/internal/exitcodes/codes_test.go
Normal file
48
cli/internal/exitcodes/codes_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package exitcodes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExitError_Error(t *testing.T) {
|
||||
e := New(NotConfigured, "not configured")
|
||||
if e.Error() != "not configured" {
|
||||
t.Fatalf("expected 'not configured', got %q", e.Error())
|
||||
}
|
||||
if e.Code != NotConfigured {
|
||||
t.Fatalf("expected code %d, got %d", NotConfigured, e.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitError_Unwrap(t *testing.T) {
|
||||
inner := fmt.Errorf("inner error")
|
||||
e := Wrap(AuthFailure, inner)
|
||||
if !errors.Is(e, inner) {
|
||||
t.Fatal("Unwrap should return the inner error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitError_Newf(t *testing.T) {
|
||||
e := Newf(Unreachable, "cannot reach %s", "server")
|
||||
if e.Error() != "cannot reach server" {
|
||||
t.Fatalf("expected 'cannot reach server', got %q", e.Error())
|
||||
}
|
||||
if e.Code != Unreachable {
|
||||
t.Fatalf("expected code %d, got %d", Unreachable, e.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitError_ErrorsAs(t *testing.T) {
|
||||
e := New(BadRequest, "bad input")
|
||||
wrapped := fmt.Errorf("wrapper: %w", e)
|
||||
|
||||
var exitErr *ExitError
|
||||
if !errors.As(wrapped, &exitErr) {
|
||||
t.Fatal("errors.As should find ExitError")
|
||||
}
|
||||
if exitErr.Code != BadRequest {
|
||||
t.Fatalf("expected code %d, got %d", BadRequest, exitErr.Code)
|
||||
}
|
||||
}
|
||||
83
cli/internal/starprompt/starprompt.go
Normal file
83
cli/internal/starprompt/starprompt.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package starprompt implements a one-time GitHub star prompt shown before the TUI.
|
||||
// Skipped when stdin/stdout is not a TTY, when gh CLI is not installed,
|
||||
// or when the user has already been prompted. State is stored in the
|
||||
// config directory so it shows at most once per user.
|
||||
package starprompt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/config"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const repo = "onyx-dot-app/onyx"
|
||||
|
||||
func statePath() string {
|
||||
return filepath.Join(config.ConfigDir(), ".star-prompted")
|
||||
}
|
||||
|
||||
func hasBeenPrompted() bool {
|
||||
_, err := os.Stat(statePath())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func markPrompted() {
|
||||
_ = os.MkdirAll(config.ConfigDir(), 0o755)
|
||||
f, err := os.Create(statePath())
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func isGHInstalled() bool {
|
||||
_, err := exec.LookPath("gh")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// MaybePrompt shows a one-time star prompt if conditions are met.
|
||||
// It is safe to call unconditionally — it no-ops when not appropriate.
|
||||
func MaybePrompt() {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return
|
||||
}
|
||||
if hasBeenPrompted() {
|
||||
return
|
||||
}
|
||||
if !isGHInstalled() {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark before asking so Ctrl+C won't cause a re-prompt.
|
||||
markPrompted()
|
||||
|
||||
fmt.Print("Enjoying Onyx? Star the repo on GitHub? [Y/n] ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
|
||||
if answer == "n" || answer == "no" {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("gh", "api", "-X", "PUT", "/user/starred/"+repo)
|
||||
cmd.Env = append(os.Environ(), "GH_PAGER=")
|
||||
if devnull, err := os.Open(os.DevNull); err == nil {
|
||||
defer func() { _ = devnull.Close() }()
|
||||
cmd.Stdin = devnull
|
||||
cmd.Stdout = devnull
|
||||
cmd.Stderr = devnull
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Println("Star us at: https://github.com/" + repo)
|
||||
} else {
|
||||
fmt.Println("Thanks for the star!")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/cli/cmd"
|
||||
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -18,6 +20,10 @@ func main() {
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
var exitErr *exitcodes.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
os.Exit(exitErr.Code)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,4 +1302,18 @@ echo ""
|
||||
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
|
||||
echo ""
|
||||
print_info "For help or issues, contact: founders@onyx.app"
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# --- GitHub star prompt (inspired by oh-my-codex) ---
|
||||
# Only prompt in interactive mode and only if gh CLI is available.
|
||||
# Uses the GitHub API directly (PUT /user/starred) like oh-my-codex.
|
||||
if is_interactive && command -v gh &>/dev/null; then
|
||||
prompt_yn_or_default "Enjoying Onyx? Star the repo on GitHub? [Y/n] " "Y"
|
||||
if [[ ! "$REPLY" =~ ^[Nn] ]]; then
|
||||
if GH_PAGER= gh api -X PUT /user/starred/onyx-dot-app/onyx < /dev/null >/dev/null 2>&1; then
|
||||
print_success "Thanks for the star!"
|
||||
else
|
||||
print_info "Star us at: https://github.com/onyx-dot-app/onyx"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -586,7 +586,10 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
|
||||
// Data / Display cell
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-column-id={cell.column.id}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
|
||||
@@ -127,13 +127,13 @@ function Main() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||
<div className="flex flex-col gap-2 desktop:flex-row desktop:items-center desktop:gap-2">
|
||||
{isApiKeySet ? (
|
||||
<>
|
||||
<Button variant="danger" onClick={handleDelete}>
|
||||
Delete API Key
|
||||
</Button>
|
||||
<Text as="p" mainContentBody text04 className="sm:mt-0">
|
||||
<Text as="p" mainContentBody text04 className="desktop:mt-0">
|
||||
Delete the current API key before updating.
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -238,9 +238,7 @@ function BuildSessionButton({
|
||||
<Text
|
||||
as="p"
|
||||
data-state={isActive ? "active" : "inactive"}
|
||||
className={cn(
|
||||
"sidebar-tab-text-defaulted line-clamp-1 break-all text-left"
|
||||
)}
|
||||
className="line-clamp-1 break-all text-left"
|
||||
mainUiBody
|
||||
>
|
||||
<TypewriterText
|
||||
|
||||
399
web/src/app/css/button.css
Normal file
399
web/src/app/css/button.css
Normal file
@@ -0,0 +1,399 @@
|
||||
/* ============================================================================
|
||||
Main Variant - Primary
|
||||
============================================================================ */
|
||||
|
||||
.button-main-primary {
|
||||
background-color: var(--theme-primary-05);
|
||||
}
|
||||
.button-main-primary:hover {
|
||||
background-color: var(--theme-primary-04);
|
||||
}
|
||||
.button-main-primary[data-state="transient"] {
|
||||
background-color: var(--theme-primary-06);
|
||||
}
|
||||
.button-main-primary:active {
|
||||
background-color: var(--theme-primary-06);
|
||||
}
|
||||
.button-main-primary:disabled {
|
||||
background-color: var(--background-neutral-04);
|
||||
}
|
||||
|
||||
.button-main-primary-text {
|
||||
color: var(--text-inverted-05) !important;
|
||||
}
|
||||
.button-main-primary:disabled .button-main-primary-text {
|
||||
color: var(--text-inverted-04) !important;
|
||||
}
|
||||
|
||||
.button-main-primary-icon {
|
||||
stroke: var(--text-inverted-05);
|
||||
}
|
||||
.button-main-primary:disabled .button-main-primary-icon {
|
||||
stroke: var(--text-inverted-04);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Main Variant - Secondary
|
||||
============================================================================ */
|
||||
|
||||
.button-main-secondary {
|
||||
background-color: var(--background-tint-01);
|
||||
border: 1px solid var(--border-01);
|
||||
}
|
||||
.button-main-secondary:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
.button-main-secondary[data-state="transient"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-main-secondary:active {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-main-secondary:disabled {
|
||||
background-color: var(--background-neutral-03);
|
||||
border: 1px solid var(--border-01);
|
||||
}
|
||||
|
||||
.button-main-secondary-text {
|
||||
color: var(--text-03) !important;
|
||||
}
|
||||
.button-main-secondary:hover .button-main-secondary-text {
|
||||
color: var(--text-04) !important;
|
||||
}
|
||||
.button-main-secondary[data-state="transient"] .button-main-secondary-text {
|
||||
color: var(--text-05) !important;
|
||||
}
|
||||
.button-main-secondary:active .button-main-secondary-text {
|
||||
color: var(--text-05) !important;
|
||||
}
|
||||
.button-main-secondary:disabled .button-main-secondary-text {
|
||||
color: var(--text-01) !important;
|
||||
}
|
||||
|
||||
.button-main-secondary-icon {
|
||||
stroke: var(--text-03);
|
||||
}
|
||||
.button-main-secondary:hover .button-main-secondary-icon {
|
||||
stroke: var(--text-04);
|
||||
}
|
||||
.button-main-secondary[data-state="transient"] .button-main-secondary-icon {
|
||||
stroke: var(--text-05);
|
||||
}
|
||||
.button-main-secondary:active .button-main-secondary-icon {
|
||||
stroke: var(--text-05);
|
||||
}
|
||||
.button-main-secondary:disabled .button-main-secondary-icon {
|
||||
stroke: var(--text-01);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Main Variant - Tertiary
|
||||
============================================================================ */
|
||||
|
||||
.button-main-tertiary {
|
||||
background-color: transparent;
|
||||
}
|
||||
.button-main-tertiary:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
.button-main-tertiary[data-state="transient"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-main-tertiary:active {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-main-tertiary:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.button-main-tertiary-text {
|
||||
color: var(--text-03) !important;
|
||||
}
|
||||
.button-main-tertiary:hover .button-main-tertiary-text {
|
||||
color: var(--text-04) !important;
|
||||
}
|
||||
.button-main-tertiary[data-state="transient"] .button-main-tertiary-text {
|
||||
color: var(--text-05) !important;
|
||||
}
|
||||
.button-main-tertiary:active .button-main-tertiary-text {
|
||||
color: var(--text-05) !important;
|
||||
}
|
||||
.button-main-tertiary:disabled .button-main-tertiary-text {
|
||||
color: var(--text-01) !important;
|
||||
}
|
||||
|
||||
.button-main-tertiary-icon {
|
||||
stroke: var(--text-03);
|
||||
}
|
||||
.button-main-tertiary:hover .button-main-tertiary-icon {
|
||||
stroke: var(--text-04);
|
||||
}
|
||||
.button-main-tertiary[data-state="transient"] .button-main-tertiary-icon {
|
||||
stroke: var(--text-05);
|
||||
}
|
||||
.button-main-tertiary:active .button-main-tertiary-icon {
|
||||
stroke: var(--text-05);
|
||||
}
|
||||
.button-main-tertiary:disabled .button-main-tertiary-icon {
|
||||
stroke: var(--text-01);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Main Variant - Internal
|
||||
============================================================================ */
|
||||
|
||||
.button-main-internal {
|
||||
background-color: transparent;
|
||||
}
|
||||
.button-main-internal:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
.button-main-internal[data-state="transient"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-main-internal:active {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-main-internal:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.button-main-internal-text {
|
||||
color: var(--text-03) !important;
|
||||
}
|
||||
.button-main-internal:hover .button-main-internal-text {
|
||||
color: var(--text-04) !important;
|
||||
}
|
||||
.button-main-internal[data-state="transient"] .button-main-internal-text {
|
||||
color: var(--text-05) !important;
|
||||
}
|
||||
.button-main-internal:active .button-main-internal-text {
|
||||
color: var(--text-05) !important;
|
||||
}
|
||||
.button-main-internal:disabled .button-main-internal-text {
|
||||
color: var(--text-01) !important;
|
||||
}
|
||||
|
||||
.button-main-internal-icon {
|
||||
stroke: var(--text-03);
|
||||
}
|
||||
.button-main-internal:hover .button-main-internal-icon {
|
||||
stroke: var(--text-04);
|
||||
}
|
||||
.button-main-internal[data-state="transient"] .button-main-internal-icon {
|
||||
stroke: var(--text-05);
|
||||
}
|
||||
.button-main-internal:active .button-main-internal-icon {
|
||||
stroke: var(--text-05);
|
||||
}
|
||||
.button-main-internal:disabled .button-main-internal-icon {
|
||||
stroke: var(--text-01);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Action Variant - Primary
|
||||
============================================================================ */
|
||||
|
||||
.button-action-primary {
|
||||
background-color: var(--action-link-05);
|
||||
}
|
||||
.button-action-primary:hover {
|
||||
background-color: var(--action-link-04);
|
||||
}
|
||||
.button-action-primary[data-state="transient"] {
|
||||
background-color: var(--action-link-06);
|
||||
}
|
||||
.button-action-primary:active {
|
||||
background-color: var(--action-link-06);
|
||||
}
|
||||
.button-action-primary:disabled {
|
||||
background-color: var(--action-link-02);
|
||||
}
|
||||
|
||||
.button-action-primary-text {
|
||||
color: var(--text-light-05) !important;
|
||||
}
|
||||
.button-action-primary:disabled .button-action-primary-text {
|
||||
color: var(--text-01) !important;
|
||||
}
|
||||
|
||||
.button-action-primary-icon {
|
||||
stroke: var(--text-light-05);
|
||||
}
|
||||
.button-action-primary:disabled .button-action-primary-icon {
|
||||
stroke: var(--text-01);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Action Variant - Secondary
|
||||
============================================================================ */
|
||||
|
||||
.button-action-secondary {
|
||||
background-color: var(--background-tint-01);
|
||||
border: 1px solid var(--border-01);
|
||||
}
|
||||
.button-action-secondary:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
.button-action-secondary[data-state="transient"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-action-secondary:active {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-action-secondary:disabled {
|
||||
background-color: var(--background-neutral-02);
|
||||
border: 1px solid var(--border-01);
|
||||
}
|
||||
|
||||
.button-action-secondary-text {
|
||||
color: var(--action-text-link-05) !important;
|
||||
}
|
||||
.button-action-secondary:disabled .button-action-secondary-text {
|
||||
color: var(--action-link-03) !important;
|
||||
}
|
||||
|
||||
.button-action-secondary-icon {
|
||||
stroke: var(--action-text-link-05);
|
||||
}
|
||||
.button-action-secondary:disabled .button-action-secondary-icon {
|
||||
stroke: var(--action-link-03);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Action Variant - Tertiary
|
||||
============================================================================ */
|
||||
|
||||
.button-action-tertiary {
|
||||
background-color: transparent;
|
||||
}
|
||||
.button-action-tertiary:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
.button-action-tertiary[data-state="transient"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-action-tertiary:active {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-action-tertiary:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.button-action-tertiary-text {
|
||||
color: var(--action-text-link-05) !important;
|
||||
}
|
||||
.button-action-tertiary:disabled .button-action-tertiary-text {
|
||||
color: var(--action-link-03) !important;
|
||||
}
|
||||
|
||||
.button-action-tertiary-icon {
|
||||
stroke: var(--action-text-link-05);
|
||||
}
|
||||
.button-action-tertiary:disabled .button-action-tertiary-icon {
|
||||
stroke: var(--action-link-03);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Danger Variant - Primary
|
||||
============================================================================ */
|
||||
|
||||
.button-danger-primary {
|
||||
background-color: var(--action-danger-05);
|
||||
}
|
||||
.button-danger-primary:hover {
|
||||
background-color: var(--action-danger-04);
|
||||
}
|
||||
.button-danger-primary[data-state="transient"] {
|
||||
background-color: var(--action-danger-06);
|
||||
}
|
||||
.button-danger-primary:active {
|
||||
background-color: var(--action-danger-06);
|
||||
}
|
||||
.button-danger-primary:disabled {
|
||||
background-color: var(--action-danger-02);
|
||||
}
|
||||
|
||||
.button-danger-primary-text {
|
||||
color: var(--text-light-05) !important;
|
||||
}
|
||||
.button-danger-primary:disabled .button-danger-primary-text {
|
||||
color: var(--text-01) !important;
|
||||
}
|
||||
|
||||
.button-danger-primary-icon {
|
||||
stroke: var(--text-light-05);
|
||||
}
|
||||
.button-danger-primary:disabled .button-danger-primary-icon {
|
||||
stroke: var(--text-01);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Danger Variant - Secondary
|
||||
============================================================================ */
|
||||
|
||||
.button-danger-secondary {
|
||||
background-color: var(--background-tint-01);
|
||||
border: 1px solid var(--border-01);
|
||||
}
|
||||
.button-danger-secondary:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
.button-danger-secondary[data-state="transient"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-danger-secondary:active {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-danger-secondary:disabled {
|
||||
background-color: var(--background-neutral-02);
|
||||
border: 1px solid var(--border-01);
|
||||
}
|
||||
|
||||
.button-danger-secondary-text {
|
||||
color: var(--action-text-danger-05) !important;
|
||||
}
|
||||
.button-danger-secondary:disabled .button-danger-secondary-text {
|
||||
color: var(--action-danger-03) !important;
|
||||
}
|
||||
|
||||
.button-danger-secondary-icon {
|
||||
stroke: var(--action-text-danger-05);
|
||||
}
|
||||
.button-danger-secondary:disabled .button-danger-secondary-icon {
|
||||
stroke: var(--action-danger-03);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Danger Variant - Tertiary
|
||||
============================================================================ */
|
||||
|
||||
.button-danger-tertiary {
|
||||
background-color: transparent;
|
||||
}
|
||||
.button-danger-tertiary:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
.button-danger-tertiary[data-state="transient"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-danger-tertiary:active {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.button-danger-tertiary:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.button-danger-tertiary-text {
|
||||
color: var(--action-text-danger-05) !important;
|
||||
}
|
||||
.button-danger-tertiary:disabled .button-danger-tertiary-text {
|
||||
color: var(--action-danger-03) !important;
|
||||
}
|
||||
|
||||
.button-danger-tertiary-icon {
|
||||
stroke: var(--action-text-danger-05);
|
||||
}
|
||||
.button-danger-tertiary:disabled .button-danger-tertiary-icon {
|
||||
stroke: var(--action-danger-03);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/* Background classes */
|
||||
.sidebar-tab-background-defaulted[data-state="active"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.sidebar-tab-background-defaulted[data-state="inactive"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
.sidebar-tab-background-defaulted:hover {
|
||||
background-color: var(--background-tint-03);
|
||||
}
|
||||
|
||||
.sidebar-tab-background-lowlight[data-state="active"] {
|
||||
background-color: var(--background-tint-00);
|
||||
}
|
||||
.sidebar-tab-background-lowlight[data-state="inactive"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
.sidebar-tab-background-lowlight:hover {
|
||||
background-color: var(--background-tint-03);
|
||||
}
|
||||
|
||||
.sidebar-tab-background-focused {
|
||||
border: 2px solid var(--background-tint-04);
|
||||
background-color: var(--background-neutral-00);
|
||||
}
|
||||
|
||||
/* Text classes */
|
||||
.sidebar-tab-text-defaulted[data-state="active"] {
|
||||
color: var(--text-04);
|
||||
}
|
||||
.sidebar-tab-text-defaulted[data-state="inactive"] {
|
||||
color: var(--text-03);
|
||||
}
|
||||
.group\/SidebarTab:hover .sidebar-tab-text-defaulted {
|
||||
color: var(--text-04);
|
||||
}
|
||||
|
||||
.sidebar-tab-text-lowlight[data-state="active"] {
|
||||
color: var(--text-03);
|
||||
}
|
||||
.sidebar-tab-text-lowlight[data-state="inactive"] {
|
||||
color: var(--text-02);
|
||||
}
|
||||
.group\/SidebarTab:hover .sidebar-tab-text-lowlight {
|
||||
color: var(--text-03);
|
||||
}
|
||||
|
||||
.sidebar-tab-text-focused {
|
||||
color: var(--text-03);
|
||||
}
|
||||
|
||||
/* Icon classes */
|
||||
.sidebar-tab-icon-defaulted[data-state="active"] {
|
||||
stroke: var(--text-04);
|
||||
}
|
||||
.sidebar-tab-icon-defaulted[data-state="inactive"] {
|
||||
stroke: var(--text-03);
|
||||
}
|
||||
.group\/SidebarTab:hover .sidebar-tab-icon-defaulted {
|
||||
stroke: var(--text-04);
|
||||
}
|
||||
|
||||
.sidebar-tab-icon-lowlight[data-state="active"] {
|
||||
stroke: var(--text-03);
|
||||
}
|
||||
.sidebar-tab-icon-lowlight[data-state="inactive"] {
|
||||
stroke: var(--text-02);
|
||||
}
|
||||
.group\/SidebarTab:hover .sidebar-tab-icon-lowlight {
|
||||
stroke: var(--text-03);
|
||||
}
|
||||
|
||||
.sidebar-tab-icon-focused {
|
||||
stroke: var(--text-02);
|
||||
}
|
||||
38
web/src/app/css/square-button.css
Normal file
38
web/src/app/css/square-button.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.square-button {
|
||||
/* Base styles */
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-08);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--background-tint-01);
|
||||
}
|
||||
|
||||
.square-button:hover {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
|
||||
.square-button:active {
|
||||
background-color: var(--background-tint-03);
|
||||
}
|
||||
|
||||
.square-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Transient state */
|
||||
.square-button[data-state="transient"] {
|
||||
border: 1px solid var(--action-link-05);
|
||||
background-color: var(--action-link-00);
|
||||
}
|
||||
|
||||
.square-button[data-state="transient"]:hover {
|
||||
background-color: var(--action-link-01);
|
||||
}
|
||||
|
||||
.square-button[data-state="transient"]:active {
|
||||
background-color: var(--action-link-02);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "css/attachment-button.css";
|
||||
@import "css/button.css";
|
||||
@import "css/card.css";
|
||||
@import "css/code.css";
|
||||
@import "css/color-swatch.css";
|
||||
@@ -8,8 +9,8 @@
|
||||
@import "css/inputs.css";
|
||||
@import "css/knowledge-table.css";
|
||||
@import "css/line-item.css";
|
||||
@import "css/sidebar-tab.css";
|
||||
@import "css/sizes.css";
|
||||
@import "css/square-button.css";
|
||||
@import "css/switch.css";
|
||||
@import "css/z-index.css";
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import AdminSidebar from "@/sections/sidebar/AdminSidebar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
@@ -8,8 +7,6 @@ import { ApplicationStatus } from "@/interfaces/settings";
|
||||
import { Button } from "@opal/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import { SvgSidebar } from "@opal/icons";
|
||||
|
||||
export interface ClientLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -52,8 +49,6 @@ const SETTINGS_LAYOUT_PREFIXES = [
|
||||
];
|
||||
|
||||
export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
const [sidebarFolded, setSidebarFolded] = useState(true);
|
||||
const { isMobile } = useScreenSize();
|
||||
const pathname = usePathname();
|
||||
const settings = useSettingsContext();
|
||||
|
||||
@@ -87,11 +82,7 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-y-auto">{children}</div>
|
||||
) : (
|
||||
<>
|
||||
<AdminSidebar
|
||||
enableCloudSS={enableCloud}
|
||||
folded={sidebarFolded}
|
||||
onFoldChange={setSidebarFolded}
|
||||
/>
|
||||
<AdminSidebar enableCloudSS={enableCloud} />
|
||||
<div
|
||||
data-main-container
|
||||
className={cn(
|
||||
@@ -99,15 +90,6 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
!hasOwnLayout && "py-10 px-4 md:px-12"
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex items-center px-4 pt-2">
|
||||
<Button
|
||||
prominence="internal"
|
||||
icon={SvgSidebar}
|
||||
onClick={() => setSidebarFolded(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Sidebar Layout Components
|
||||
*
|
||||
* Provides composable layout primitives for app and admin sidebars with mobile
|
||||
* overlay support and optional desktop folding.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import * as SidebarLayouts from "@/layouts/sidebar-layouts";
|
||||
* import { useSidebarFolded } from "@/layouts/sidebar-layouts";
|
||||
*
|
||||
* function MySidebar() {
|
||||
* const { folded, setFolded } = useSidebarState();
|
||||
* const contentFolded = useSidebarFolded();
|
||||
*
|
||||
* return (
|
||||
* <SidebarLayouts.Root folded={folded} onFoldChange={setFolded} foldable>
|
||||
* <SidebarLayouts.Header>
|
||||
* <NewSessionButton folded={contentFolded} />
|
||||
* </SidebarLayouts.Header>
|
||||
* <SidebarLayouts.Body scrollKey="my-sidebar">
|
||||
* {contentFolded ? null : <SectionContent />}
|
||||
* </SidebarLayouts.Body>
|
||||
* <SidebarLayouts.Footer>
|
||||
* <UserAvatar />
|
||||
* </SidebarLayouts.Footer>
|
||||
* </SidebarLayouts.Root>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import OverflowDiv from "@/refresh-components/OverflowDiv";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fold context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SidebarFoldedContext = createContext(false);
|
||||
|
||||
/**
|
||||
* Returns whether the sidebar content should render in its folded (narrow)
|
||||
* state. On mobile, this is always `false` because the overlay pattern handles
|
||||
* visibility — the sidebar content itself is always fully expanded.
|
||||
*/
|
||||
export function useSidebarFolded(): boolean {
|
||||
return useContext(SidebarFoldedContext);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarRootProps {
|
||||
/**
|
||||
* Whether the sidebar is currently folded (desktop) or off-screen (mobile).
|
||||
*/
|
||||
folded: boolean;
|
||||
/** Callback to update the fold state. Compatible with `useState` setters. */
|
||||
onFoldChange: Dispatch<SetStateAction<boolean>>;
|
||||
/**
|
||||
* Whether the sidebar supports folding on desktop.
|
||||
* When `false` (the default), the sidebar is always expanded on desktop and
|
||||
* the fold button is hidden. Mobile overlay behavior is always enabled
|
||||
* regardless of this prop.
|
||||
*/
|
||||
foldable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarRoot({
|
||||
folded,
|
||||
onFoldChange,
|
||||
foldable = false,
|
||||
children,
|
||||
}: SidebarRootProps) {
|
||||
const { isMobile, isMediumScreen } = useScreenSize();
|
||||
|
||||
const close = useCallback(() => onFoldChange(true), [onFoldChange]);
|
||||
const toggle = useCallback(
|
||||
() => onFoldChange((prev) => !prev),
|
||||
[onFoldChange]
|
||||
);
|
||||
|
||||
// On mobile the sidebar content is always visually expanded — the overlay
|
||||
// transform handles visibility. On desktop, only foldable sidebars honour
|
||||
// the fold state.
|
||||
const contentFolded = !isMobile && foldable ? folded : false;
|
||||
|
||||
const inner = (
|
||||
<div className="flex flex-col min-h-0 h-full gap-3">{children}</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<SidebarFoldedContext.Provider value={false}>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 transition-transform duration-200",
|
||||
folded ? "-translate-x-full" : "translate-x-0"
|
||||
)}
|
||||
>
|
||||
<SidebarWrapper folded={false} onFoldClick={close}>
|
||||
{inner}
|
||||
</SidebarWrapper>
|
||||
</div>
|
||||
|
||||
{/* Backdrop — closes the sidebar when anything outside it is tapped */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-mask-03 backdrop-blur-03 transition-opacity duration-200",
|
||||
folded
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "opacity-100 pointer-events-auto"
|
||||
)}
|
||||
onClick={close}
|
||||
/>
|
||||
</SidebarFoldedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Medium screens: the folded strip stays visible in the layout flow;
|
||||
// expanding overlays content instead of pushing it.
|
||||
if (isMediumScreen) {
|
||||
return (
|
||||
<SidebarFoldedContext.Provider value={folded}>
|
||||
{/* Spacer reserves the folded sidebar width in the flex layout */}
|
||||
<div className="shrink-0 w-[3.25rem]" />
|
||||
|
||||
{/* Sidebar — fixed so it overlays content when expanded */}
|
||||
<div className="fixed inset-y-0 left-0 z-50">
|
||||
<SidebarWrapper folded={folded} onFoldClick={toggle}>
|
||||
{inner}
|
||||
</SidebarWrapper>
|
||||
</div>
|
||||
|
||||
{/* Backdrop when expanded — blur only, no tint */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 backdrop-blur-03 transition-opacity duration-200",
|
||||
folded
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "opacity-100 pointer-events-auto"
|
||||
)}
|
||||
onClick={close}
|
||||
/>
|
||||
</SidebarFoldedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarFoldedContext.Provider value={contentFolded}>
|
||||
<SidebarWrapper
|
||||
folded={foldable ? folded : undefined}
|
||||
onFoldClick={foldable ? toggle : undefined}
|
||||
>
|
||||
{inner}
|
||||
</SidebarWrapper>
|
||||
</SidebarFoldedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header — pinned content above the scroll area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarHeader({ children }: SidebarHeaderProps) {
|
||||
if (!children) return null;
|
||||
return <div className="px-2">{children}</div>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body — scrollable content area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarBodyProps {
|
||||
/**
|
||||
* Unique key to enable scroll position persistence across navigation.
|
||||
* (e.g., "admin-sidebar", "app-sidebar").
|
||||
*/
|
||||
scrollKey: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarBody({ scrollKey, children }: SidebarBodyProps) {
|
||||
const folded = useSidebarFolded();
|
||||
return (
|
||||
<OverflowDiv
|
||||
className={cn("gap-3 px-2", folded && "hidden")}
|
||||
scrollKey={scrollKey}
|
||||
>
|
||||
{children}
|
||||
</OverflowDiv>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer — pinned content below the scroll area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SidebarFooterProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SidebarFooter({ children }: SidebarFooterProps) {
|
||||
if (!children) return null;
|
||||
return <div className="px-2">{children}</div>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
SidebarRoot as Root,
|
||||
SidebarHeader as Header,
|
||||
SidebarBody as Body,
|
||||
SidebarFooter as Footer,
|
||||
};
|
||||
@@ -122,7 +122,7 @@ export const ART_ASSISTANT_ID = -3;
|
||||
export const MAX_FILES_TO_SHOW = 3;
|
||||
|
||||
// SIZES
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 724;
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 640;
|
||||
export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
|
||||
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
|
||||
export const DEFAULT_AVATAR_SIZE_PX = 18;
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import SidebarSection from "@/sections/sidebar/SidebarSection";
|
||||
import * as SidebarLayouts from "@/layouts/sidebar-layouts";
|
||||
import { useSidebarFolded } from "@/layouts/sidebar-layouts";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import { useIsKGExposed } from "@/app/admin/kg/utils";
|
||||
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
@@ -20,9 +12,10 @@ import { UserRole } from "@/lib/types";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { CombinedSettings } from "@/interfaces/settings";
|
||||
import { SidebarTab } from "@opal/components";
|
||||
import SidebarBody from "@/sections/sidebar/SidebarBody";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { SvgArrowUpCircle, SvgSearch, SvgUserManage, SvgX } from "@opal/icons";
|
||||
import { SvgArrowUpCircle, SvgUserManage, SvgX } from "@opal/icons";
|
||||
import {
|
||||
useBillingInformation,
|
||||
useLicense,
|
||||
@@ -191,29 +184,9 @@ function groupBySection(items: SidebarItemEntry[]) {
|
||||
|
||||
interface AdminSidebarProps {
|
||||
enableCloudSS: boolean;
|
||||
folded: boolean;
|
||||
onFoldChange: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
interface AdminSidebarInnerProps {
|
||||
enableCloudSS: boolean;
|
||||
onFoldChange: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
function AdminSidebarInner({
|
||||
enableCloudSS,
|
||||
onFoldChange,
|
||||
}: AdminSidebarInnerProps) {
|
||||
const folded = useSidebarFolded();
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [focusSearch, setFocusSearch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusSearch && !folded && searchRef.current) {
|
||||
searchRef.current.focus();
|
||||
setFocusSearch(false);
|
||||
}
|
||||
}, [focusSearch, folded]);
|
||||
export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
const { kgExposed } = useIsKGExposed();
|
||||
const pathname = usePathname();
|
||||
const { customAnalyticsEnabled } = useCustomAnalyticsEnabled();
|
||||
@@ -254,78 +227,28 @@ function AdminSidebarInner({
|
||||
const groups = groupBySection(filtered);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarLayouts.Header>
|
||||
<div className="flex flex-col w-full">
|
||||
<SidebarTab
|
||||
icon={({ className }) => <SvgX className={className} size={16} />}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
folded={folded}
|
||||
>
|
||||
Exit Admin Panel
|
||||
</SidebarTab>
|
||||
{folded ? (
|
||||
<SidebarWrapper>
|
||||
<SidebarBody
|
||||
scrollKey="admin-sidebar"
|
||||
pinnedContent={
|
||||
<div className="flex flex-col w-full">
|
||||
<SidebarTab
|
||||
icon={SvgSearch}
|
||||
folded
|
||||
onClick={() => {
|
||||
onFoldChange(false);
|
||||
setFocusSearch(true);
|
||||
}}
|
||||
icon={({ className }) => <SvgX className={className} size={16} />}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
>
|
||||
Search
|
||||
Exit Admin Panel
|
||||
</SidebarTab>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
ref={searchRef}
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarLayouts.Header>
|
||||
|
||||
<SidebarLayouts.Body scrollKey="admin-sidebar">
|
||||
{groups.map((group, groupIndex) => {
|
||||
const tabs = group.items.map(({ link, icon, name, disabled }) => (
|
||||
<Disabled key={link} disabled={disabled}>
|
||||
{/*
|
||||
# NOTE (@raunakab)
|
||||
We intentionally add a `div` intermediary here.
|
||||
Without it, the disabled styling that is default provided by the `Disabled` component (which we want here) would be overridden by the custom disabled styling provided by the `SidebarTab`.
|
||||
Therefore, in order to avoid that overriding, we add a layer of indirection.
|
||||
*/}
|
||||
<div>
|
||||
<SidebarTab
|
||||
disabled={disabled}
|
||||
icon={icon}
|
||||
href={disabled ? undefined : link}
|
||||
selected={pathname.startsWith(link)}
|
||||
>
|
||||
{name}
|
||||
</SidebarTab>
|
||||
</div>
|
||||
</Disabled>
|
||||
));
|
||||
|
||||
if (!group.section) {
|
||||
return <div key={groupIndex}>{tabs}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSection key={groupIndex} title={group.section}>
|
||||
{tabs}
|
||||
</SidebarSection>
|
||||
);
|
||||
})}
|
||||
</SidebarLayouts.Body>
|
||||
|
||||
<SidebarLayouts.Footer>
|
||||
{!folded && (
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Section gap={0} height="fit" alignItems="start">
|
||||
<div className="p-[0.38rem] w-full">
|
||||
<Content
|
||||
@@ -361,23 +284,41 @@ function AdminSidebarInner({
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</SidebarLayouts.Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
{groups.map((group, groupIndex) => {
|
||||
const tabs = group.items.map(({ link, icon, name, disabled }) => (
|
||||
<Disabled key={link} disabled={disabled}>
|
||||
{/*
|
||||
# NOTE (@raunakab)
|
||||
We intentionally add a `div` intermediary here.
|
||||
Without it, the disabled styling that is default provided by the `Disabled` component (which we want here) would be overridden by the custom disabled styling provided by the `SidebarTab`.
|
||||
Therefore, in order to avoid that overriding, we add a layer of indirection.
|
||||
*/}
|
||||
<div>
|
||||
<SidebarTab
|
||||
disabled={disabled}
|
||||
icon={icon}
|
||||
href={disabled ? undefined : link}
|
||||
selected={pathname.startsWith(link)}
|
||||
>
|
||||
{name}
|
||||
</SidebarTab>
|
||||
</div>
|
||||
</Disabled>
|
||||
));
|
||||
|
||||
export default function AdminSidebar({
|
||||
enableCloudSS,
|
||||
folded,
|
||||
onFoldChange,
|
||||
}: AdminSidebarProps) {
|
||||
return (
|
||||
<SidebarLayouts.Root folded={folded} onFoldChange={onFoldChange}>
|
||||
<AdminSidebarInner
|
||||
enableCloudSS={enableCloudSS}
|
||||
onFoldChange={onFoldChange}
|
||||
/>
|
||||
</SidebarLayouts.Root>
|
||||
if (!group.section) {
|
||||
return <div key={groupIndex}>{tabs}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSection key={groupIndex} title={group.section}>
|
||||
{tabs}
|
||||
</SidebarSection>
|
||||
);
|
||||
})}
|
||||
</SidebarBody>
|
||||
</SidebarWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,13 +65,11 @@ module.exports = {
|
||||
"neutral-10": "var(--neutral-10) 5%",
|
||||
},
|
||||
screens: {
|
||||
sm: "724px",
|
||||
md: "912px",
|
||||
lg: "1232px",
|
||||
"2xl": "1420px",
|
||||
"3xl": "1700px",
|
||||
"4xl": "2000px",
|
||||
mobile: { max: "724px" },
|
||||
mobile: { max: "767px" },
|
||||
desktop: "768px",
|
||||
tall: { raw: "(min-height: 800px)" },
|
||||
short: { raw: "(max-height: 799px)" },
|
||||
"very-short": { raw: "(max-height: 600px)" },
|
||||
|
||||
@@ -59,7 +59,10 @@ for (const theme of THEMES) {
|
||||
|
||||
await expectScreenshot(page, {
|
||||
name: `admin-${theme}-${slug}`,
|
||||
mask: ['[data-testid="admin-date-range-selector-button"]'],
|
||||
mask: [
|
||||
'[data-testid="admin-date-range-selector-button"]',
|
||||
'[data-column-id="updated_at"]',
|
||||
],
|
||||
});
|
||||
},
|
||||
{ box: true }
|
||||
|
||||
@@ -142,8 +142,6 @@ test.describe("Chat Search Command Menu", () => {
|
||||
}
|
||||
|
||||
await expect(dialog.getByText("Sessions")).toBeVisible();
|
||||
|
||||
await expectScreenshot(page, { name: "command-menu-sessions-filter" });
|
||||
});
|
||||
|
||||
test('"Projects" filter expands to show all 4 projects', async ({ page }) => {
|
||||
|
||||
@@ -135,6 +135,65 @@ export async function waitForAnimations(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for every **visible** `<img>` on the page to finish loading (or error).
|
||||
*
|
||||
* This prevents screenshot flakiness caused by images that have been added to
|
||||
* the DOM but haven't been decoded yet — `networkidle` only guarantees that
|
||||
* fewer than 2 connections are in flight, not that every image is painted.
|
||||
*
|
||||
* Only images that are actually visible and in (or near) the viewport are
|
||||
* waited on. Hidden images (e.g. the `dark:hidden` / `hidden dark:block`
|
||||
* alternates created by `createLogoIcon`) and offscreen lazy-loaded images
|
||||
* are skipped so they don't force a needless timeout.
|
||||
*
|
||||
* Times out after `timeoutMs` (default 5 000 ms) so a single broken image
|
||||
* doesn't block the entire test forever.
|
||||
*/
|
||||
export async function waitForImages(
|
||||
page: Page,
|
||||
timeoutMs: number = 5_000
|
||||
): Promise<void> {
|
||||
await page.evaluate(async (timeout) => {
|
||||
const images = Array.from(document.querySelectorAll("img")).filter(
|
||||
(img) => {
|
||||
// Skip images hidden via CSS (display:none, visibility:hidden, etc.)
|
||||
// This covers createLogoIcon's dark-mode alternates.
|
||||
const style = getComputedStyle(img);
|
||||
if (
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
style.opacity === "0"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip images that have no layout box (zero size or detached).
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) return false;
|
||||
|
||||
// Skip images far below the viewport (lazy-loaded, not yet needed).
|
||||
if (rect.top > window.innerHeight * 2) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
Promise.allSettled(
|
||||
images.map((img) => {
|
||||
if (img.complete) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
img.addEventListener("load", () => resolve(), { once: true });
|
||||
img.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
})
|
||||
),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
||||
]);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and optionally assert it matches the stored baseline.
|
||||
*
|
||||
@@ -188,6 +247,10 @@ export async function expectScreenshot(
|
||||
page.locator(selector)
|
||||
);
|
||||
|
||||
// Wait for images to finish loading / decoding so that logo icons
|
||||
// and other <img> elements are fully painted before the screenshot.
|
||||
await waitForImages(page);
|
||||
|
||||
// Wait for any in-flight CSS animations / transitions to settle so that
|
||||
// screenshots are deterministic (e.g. slide-in card animations on the
|
||||
// onboarding flow).
|
||||
@@ -279,6 +342,9 @@ export async function expectElementScreenshot(
|
||||
page.locator(selector)
|
||||
);
|
||||
|
||||
// Wait for images to finish loading / decoding.
|
||||
await waitForImages(page);
|
||||
|
||||
// Wait for any in-flight CSS animations / transitions to settle so that
|
||||
// element screenshots are deterministic (same reasoning as expectScreenshot).
|
||||
await waitForAnimations(page);
|
||||
|
||||
Reference in New Issue
Block a user