Compare commits

..

14 Commits

Author SHA1 Message Date
rohoswagger
f5970f8f7f fix(cli): reject conflicting positional arg + --prompt
Passing both a positional argument and --prompt silently dropped the
--prompt value. Now fails with a clear error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:40:02 -07:00
rohoswagger
0eaab180dd fix(cli): add missing exitcodes package files
The exitcodes package was created on disk but never staged/committed
because ez commit -am only picks up tracked files. Adding the new
untracked files that all commands depend on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:33:20 -07:00
rohoswagger
9399cc7548 fix(cli): don't persist env var overrides when saving config
configureNonInteractive used config.Load() which applies ONYX_PERSONA_ID
env overrides, causing the env value to leak into the saved config file.
Now uses LoadFromDisk() to read only persisted values. Also extracts
LoadFromDisk as a reusable function from Load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:31:52 -07:00
rohoswagger
9c3a85d1fc fix(cli): stream truncated output to disk instead of buffering in memory
overflowWriter now opens a temp file eagerly when limit > 0 and streams
chunks directly to disk. Previously it accumulated the full response in
a strings.Builder, which could cause large memory growth on long outputs.
The in-memory buf is now only used for quiet mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:31:11 -07:00
rohoswagger
2faa475c83 fix(cli): reject --json + --quiet flag combination
Using both flags silently dropped all JSON events, returning exit 0 with
empty stdout. Now rejects the combination upfront with a clear error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:28:38 -07:00
rohoswagger
52d926f002 feat(cli): add Long descriptions to agents, validate-config, and chat
Every command now has a Long description for --help consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:26:22 -07:00
rohoswagger
718227a336 fix(cli): cap stdin read at 10MB and remove duplicate serve examples
Review fixes:
- ask: limit stdin reads to 10MB to prevent OOM on large pipes
- serve: remove duplicate examples from Long description (Cobra
  renders the Example field separately)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:21:51 -07:00
rohoswagger
73cd88a708 feat(cli): add --dry-run flag for configure
Allows testing a server URL and API key combination without saving the
config. Useful for agents and scripts that want to validate credentials
before committing to a configuration change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:21:51 -07:00
rohoswagger
b08f50fa53 feat(cli): add semantic exit codes and quiet mode
Phase 2 of agent-friendly CLI improvements:

- New exitcodes package with typed ExitError: agents can distinguish
  not-configured (2), auth failure (3), unreachable (4), bad request (5)
- main.go extracts exit codes via errors.As for proper os.Exit
- All commands updated to return typed ExitError where appropriate
- ask: add --quiet/-q flag to buffer output and print once at end
  (no streaming chunks, useful for scripting)
- Unit tests for exitcodes package and quiet mode overflow writer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:21:51 -07:00
rohoswagger
ea8366aa69 feat(cli): make onyx-cli agent-friendly with stdin, auto-truncate, and non-interactive configure
Phase 1 of agent-friendly CLI improvements:

- ask: support piped stdin input with --prompt flag for separating question
  from context (arg+stdin, prompt+stdin, stdin-only all work)
- ask: auto-truncate output at 4KB when stdout is not a TTY (agent calling),
  save full response to temp file with exploration hints. --max-output to
  override threshold, --max-output 0 to disable
- configure: accept --server-url and --api-key flags for non-interactive
  setup (tests connection before saving)
- All commands: actionable error messages with remediation hints
- All commands: help examples added to every subcommand
- New unit tests for resolveQuestion and overflowWriter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:21:51 -07:00
roshan
e6f7c2b45c feat(install): add GitHub star prompt at end of install script (#9861) 2026-04-02 19:12:10 +00:00
Raunak Bhagat
f77128d929 refactor: move SidebarTab to Opal with disabled prop and variant/state API (v2) (#9866) 2026-04-02 19:07:52 +00:00
Jamison Lahman
1d4ca769e7 chore(playwright): stabalize icon loading, users table timestamp (#9864) 2026-04-02 18:58:28 +00:00
Raunak Bhagat
e002f6c195 Revert "refactor: move SidebarTab to opal" (#9865) 2026-04-02 11:38:03 -07:00
28 changed files with 1628 additions and 996 deletions

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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 != "" {

View File

@@ -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")

View File

@@ -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

View 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...)}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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>
</>

View File

@@ -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
View 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);
}

View File

@@ -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);
}

View 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);
}

View File

@@ -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";

View File

@@ -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>
</>

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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

View File

@@ -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)" },

View File

@@ -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 }

View File

@@ -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 }) => {

View File

@@ -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);