Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 804b193ac7 | |||
| d397d3a1f6 | |||
| 67210bcd61 | |||
| 4e0b869dd3 | |||
| cfe832383c | |||
| 0737786faf | |||
| 428519335d | |||
| faa56f5c75 | |||
| 7bc4594b94 | |||
| 6153d50fa8 | |||
| 562f43fd88 | |||
| 8ea40260ad | |||
| 6353e88c9e | |||
| 6cf8a82889 | |||
| 2ea2184372 | |||
| aff863c69f | |||
| 764b967856 | |||
| 94ee2dc79c | |||
| 3f50e162e6 | |||
| 1f5c04e765 | |||
| 952ee7c724 | |||
| 92cee94431 | |||
| 1b561c22d9 | |||
| 32a8887419 | |||
| f77049c238 | |||
| 64017c6ca5 | |||
| 9156781d75 | |||
| 8d48a038c7 | |||
| 59b3ba9e39 | |||
| eab7c437df | |||
| e217314248 | |||
| 26b0fe53c7 | |||
| 4227a97ab1 | |||
| 0004cf7036 | |||
| 75a2de60b2 | |||
| 1aee080fca | |||
| 470f9909b4 | |||
| 5ba9e07d78 | |||
| b0e8368c1d | |||
| 686b80bd99 | |||
| 02ee46c862 | |||
| 5bd1c0323f | |||
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 | |||
| 2fe1dcd46f | |||
| 7fae2f48c0 | |||
| 229002cc2b | |||
| 27923db4e0 | |||
| 5ab674f134 | |||
| 00a2aedb09 | |||
| 9128352d1b | |||
| fb3796f064 | |||
| c3f8dec104 | |||
| 6e685ce1c3 | |||
| 29a5da3e4e | |||
| 713e9fe58b | |||
| 13bc7154d6 | |||
| e895c0e8eb | |||
| 0a86dfb8c5 | |||
| fc7b58f15b | |||
| c4025cd2f7 | |||
| ee1e07bb5e |
@@ -38,5 +38,9 @@
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
+35
-19
@@ -346,8 +346,9 @@ graph TD
|
||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
|
||||
| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus `POST /profiles/usage` (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see `cc-usage.js`), plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json` (on **macOS**: out of the login Keychain item `Claude Code-credentials`, which is what the CLI reads there — the file is only a fallback, and `AGENTHUB_CC_KEYCHAIN=0` pins it), and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
|
||||
| `lib/cc-usage.js` | How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has `subscriptionType`/`rateLimitTier` and nothing else), so it asks `GET https://api.anthropic.com/api/oauth/usage`, the same endpoint the CLI's own `/usage` reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and **nothing here runs on a poll**. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through `cc-profiles.writeProfile()` because a dropped refresh leaves an account that needs a fresh `claude auth login`. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh |
|
||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
|
||||
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
|
||||
@@ -357,7 +358,7 @@ graph TD
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
|
||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
|
||||
@@ -367,6 +368,8 @@ graph TD
|
||||
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
|
||||
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff; `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. Deliberately the boring half of git — no merge, rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
|
||||
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||
|
||||
@@ -467,6 +470,8 @@ graph TD
|
||||
### Splash & loading UX
|
||||
|
||||
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
|
||||
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
||||
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
|
||||
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
||||
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
||||
|
||||
@@ -600,8 +605,8 @@ graph LR
|
||||
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
|
||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
||||
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
||||
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
|
||||
| `/*` | NotFound | None (static 404 page) |
|
||||
|
||||
### Activity Feed Interaction Model
|
||||
@@ -1576,15 +1581,14 @@ last node carries `detect` rules, so a lane can never be auto-declared `done`.
|
||||
### Codex evidence reliability (`server/lib/codex-nudge.js`)
|
||||
|
||||
Auto-advance moves the ring but can never supply evidence; only the session's
|
||||
own `agenthub stage --evidence` call can, so three mechanisms make that more likely
|
||||
without weakening declared-wins/inferred-never-`done`:
|
||||
own `agenthub stage --evidence` call can, so two mechanisms make that more likely
|
||||
without weakening declared-wins/inferred-never-`done`. (Opening-prompt priming
|
||||
used to be a third: `LaneConsolePane.tsx` pre-filled the prompt box from the
|
||||
lane's pipeline — the driving slash command on a Claude lane, a plain-text
|
||||
stage checklist on a codex one. It is gone: whatever sits in that box is sent
|
||||
as a real prompt when the run starts, so the box now stays empty and the
|
||||
prompt is the user's alone.)
|
||||
|
||||
- **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex
|
||||
pane's first prompt is pre-filled with a plain-text checklist of
|
||||
`agenthub stage <stage> --tool codex` calls, one per node of `Lane.pipeline_nodes`
|
||||
— the same up-front nudge a Claude lane gets from its pre-filled
|
||||
`/agenthub:ship-feature-lane` skill command, in plain text since Codex has no
|
||||
slash commands.
|
||||
- **`AGENTS.md` stage block** (`server/lib/lane-agents.js`): on a `codex` lane,
|
||||
`agenthub lanes agents install` writes a marker-delimited
|
||||
`<!-- agenthub:stage:start -->` section into `<lane cwd>/AGENTS.md` instead of
|
||||
@@ -1912,7 +1916,7 @@ graph TD
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **VAPID Pipeline** | Uses the `web-push` library on the server. VAPID keys are auto-generated on first run and persisted to `data/vapid-keys.json` to ensure subscription continuity. |
|
||||
| **Service Worker** | Located at `client/public/sw.js`. It runs independently of the dashboard tab, listening for `push` events from the browser's push service. It handles `notificationclick` to focus/open the dashboard. |
|
||||
| **Service Worker** | Located at `client/public/sw.js`. It runs independently of the dashboard tab, listening for `push` events from the browser's push service. It handles `notificationclick` by navigating (`client.navigate`, falling back to `clients.openWindow`) an existing dashboard tab to the notification's `data.url` before focusing it — so a click lands on the lane that raised it, not just whichever tab happened to be open. |
|
||||
| **macOS Audio Support** | Notifications are explicitly sent with `silent: false` and `sound: "default"`. This overrides macOS behavior that would otherwise suppress audio for web notifications. |
|
||||
| **Subscription Management** | The dashboard registers the service worker and requests a `PushSubscription`. This subscription (endpoint and keys) is stored in the `push_subscriptions` table, indexed by endpoint. |
|
||||
| **Event Routing** | When a WebSocket event (e.g., `session_created`) is broadcast, the server also triggers `sendPushToAll()`, which iterates through active subscriptions and sends signed VAPID payloads. |
|
||||
@@ -1933,6 +1937,16 @@ flowchart TD
|
||||
style SHOW fill:#10b981,stroke:#34d399,color:#fff
|
||||
```
|
||||
|
||||
### Delivery fallback
|
||||
|
||||
`POST /api/push/send` answering `200` means the request was **accepted**, not that anyone was notified: it reports `{ok, native, pushed, failed}`, and a browser that never subscribed (no service worker, a non-secure origin such as `http://<lan-ip>:4820`, a push service the server cannot reach) gets `native:false, pushed:0`. `notify()` in `useNotifications.ts` therefore treats "delivered" as `native || pushed > 0` and otherwise shows the notification locally through the service worker / `Notification` — a fetch that merely resolved is not proof. The fallback is skipped when the push did land, so nothing is shown twice.
|
||||
|
||||
Note the browser-side floor this cannot lift: the `Notification` API needs a secure context, so open the dashboard on `http://localhost` (secure by definition) or behind HTTPS — on a bare LAN IP no notification of any kind is possible.
|
||||
|
||||
### Click-through to the lane
|
||||
|
||||
Every `notify()` call in `useNotifications.ts` resolves a per-event `url` — `/workspace?lane=<id>` — by matching the event's `session_id` against a lane whose `session_id` currently equals it (a lightweight lane cache the hook keeps synced off `lane_update` broadcasts, seeded once via `GET /api/lanes`). `POST /api/push/send`'s optional `url` field carries that path through the VAPID payload as `data.url`; `sw.js`'s `notificationclick` handler reads it back to navigate. The `Workspace` page consumes `?lane=<id>` in a one-shot effect (selects the lane, then strips the param via `replace: true` so it doesn't override a later manual lane switch). No lane match (event predates lane tracking, or the lane was removed) — `url` stays `undefined` and the click just focuses the dashboard, the prior behavior.
|
||||
|
||||
### Preference Storage
|
||||
|
||||
Notification preferences remain in `localStorage` (`agent-monitor-notifications`) for UI-side filtering, while the actual push delivery is managed by the server-side subscription store.
|
||||
@@ -1940,10 +1954,12 @@ Notification preferences remain in `localStorage` (`agent-monitor-notifications`
|
||||
| Preference | UI Key | Logic |
|
||||
| --- | --- | --- |
|
||||
| Master Toggle | `enabled` | Controls whether the subscription is active |
|
||||
| New Session | `onNewSession` | Filtered during push fan-out |
|
||||
| Session Error | `onSessionError` | Filtered during push fan-out |
|
||||
| Session Complete | `onSessionComplete` | Filtered during push fan-out |
|
||||
| Subagent Spawn | `onSubagentSpawn` | Filtered during push fan-out |
|
||||
| New Session | `onNewSession` | Filtered client-side in `useNotifications` |
|
||||
| Session Error | `onSessionError` | Filtered client-side in `useNotifications` |
|
||||
| Awaiting Input | `onAwaitingInput` | `Notification` events — Claude is blocked on the user. Default ON |
|
||||
| Turn Complete | `onTurnComplete` | `Stop` events — Claude finished a turn. Default ON |
|
||||
| Session Complete | `onSessionComplete` | `SessionEnd` events. Filtered client-side |
|
||||
| Subagent Spawn | `onSubagentSpawn` | Filtered client-side in `useNotifications` |
|
||||
|
||||
### Service Worker Caching
|
||||
|
||||
@@ -2381,7 +2397,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
|
||||
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
||||
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light toggle re-themes the app with one class flip rather than per-component `dark:` variants |
|
||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
|
||||
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
||||
| **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) |
|
||||
| **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues |
|
||||
|
||||
@@ -42,6 +42,13 @@ Internal build — all rights reserved.
|
||||
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
|
||||
setting. **This is the part that needs `tmux` installed** — see
|
||||
[Prerequisites](#prerequisites).
|
||||
- **Git, from the dashboard.** Each lane card shows its branch, `↑ahead ↓behind`
|
||||
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
||||
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
||||
Beside the lane sits a collapsible **Changes** panel — an overview of the
|
||||
changed files, a full review of every diff, or one file at a time, comparing
|
||||
either uncommitted work or the whole branch against its base. There is no
|
||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per lane (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
|
||||
@@ -57,6 +64,10 @@ Internal build — all rights reserved.
|
||||
`settings.json` stay read-only: they race with the live CLI.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark; light:
|
||||
Default, Apple Light, IntelliJ Light, Canvas. Each mode remembers its own
|
||||
theme, so the sidebar's quick toggle swaps between the two. Per-browser only.
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
|
||||
@@ -219,9 +230,11 @@ A lane is a working directory the dashboard watches. Two kinds:
|
||||
|
||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
||||
never reset or deleted.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It owns
|
||||
the full lifecycle and may reset or remove it, behind a three-check destroy
|
||||
guard and a counted preflight the caller has to echo back.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
||||
the caller has to echo back. Removing a lane — either kind — deletes only the
|
||||
dashboard's record of it: the working tree, its branch and its database stay,
|
||||
for you to delete yourself.
|
||||
|
||||
```bash
|
||||
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||
@@ -240,9 +253,9 @@ also **advances the declared stage** — the one place an inference moves the bl
|
||||
`current` ring, because a Codex session has no hook and nothing else would ever
|
||||
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
|
||||
node stays amber (`passed-no-evidence`), never green, since only the session
|
||||
itself can say what it finished. Three things help it get there: the run console
|
||||
pre-fills a codex pane's opening prompt with a stage-declaration checklist built
|
||||
from its own pipeline nodes, `agenthub lanes agents install` writes the same
|
||||
itself can say what it finished. Two things help it get there (the run console
|
||||
no longer pre-fills anything into the prompt box — that box is the user's to
|
||||
type): `agenthub lanes agents install` writes the stage-declaration
|
||||
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
|
||||
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
|
||||
a lane's pane if its current stage sits without evidence for more than two
|
||||
@@ -276,7 +289,9 @@ lane. This is resource namespacing on the host, not a container: lanes run as th
|
||||
same user and share the network.
|
||||
|
||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
||||
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
|
||||
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
||||
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
||||
`POST /:id/git/:action`), and the full
|
||||
runtime/profile contract.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: backend-reviewer
|
||||
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: opus
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
|
||||
+10
-5
@@ -1799,9 +1799,14 @@ async function cmdLanesLifecycle(action, args) {
|
||||
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||
console.log(` database: ${preflight.database} (${fate})`);
|
||||
}
|
||||
if (action === "remove") {
|
||||
console.log(
|
||||
" Only the dashboard's record is dropped — the working tree, its branch and the database are left for you to delete yourself."
|
||||
);
|
||||
}
|
||||
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
|
||||
console.log("Warnings:");
|
||||
for (const warning of preflight.warnings) console.log(` ${warning}`);
|
||||
@@ -1813,9 +1818,9 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
// Only `reset` is impossible for an adopted lane. `remove` IS supported by the
|
||||
// server: it drops the dashboard's record and never touches the directory
|
||||
// (server/routes/lanes.js scopes the worktree teardown to managed lanes), so
|
||||
// refusing it here would make a permitted action unreachable from the CLI.
|
||||
// server for every lane kind: it drops the dashboard's record and never touches
|
||||
// the directory, so refusing it here would make a permitted action unreachable
|
||||
// from the CLI.
|
||||
if (
|
||||
action === "reset" &&
|
||||
Array.isArray(preflight.blocked) &&
|
||||
@@ -2936,7 +2941,7 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
"lanes reset|remove|purge",
|
||||
"<id> [--force] [--keep-db] --yes",
|
||||
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
|
||||
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
|
||||
],
|
||||
[
|
||||
"lanes up|down",
|
||||
|
||||
+31
-2
@@ -52,7 +52,17 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
Other rules that fall out of this:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
|
||||
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
|
||||
- **React Router 6.28** - Client-side routing with nested layouts
|
||||
- **WebSocket** - Real-time event streaming from server
|
||||
- **Lucide Icons** - Modern, consistent icon set
|
||||
@@ -198,7 +208,7 @@ client/
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
|
||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||
│ │
|
||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||||
│ ├── App.tsx # Root component + router setup
|
||||
@@ -726,6 +736,25 @@ flowchart LR
|
||||
|
||||
Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button.
|
||||
|
||||
#### Tooltips (`components/Tooltip.tsx`)
|
||||
|
||||
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
|
||||
app the same designed tooltip — there is no wrapper to add at the call site.
|
||||
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
|
||||
focus (immediately) it takes the element's native `title`, shows it as an
|
||||
anchored bubble with an arrow, and puts the attribute back when the bubble
|
||||
closes. Consequences worth knowing:
|
||||
|
||||
- **Write `title="…"` exactly as before.** That is the whole API. A control
|
||||
keeps its native tooltip, and its accessible name, when JS has not run.
|
||||
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
|
||||
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
|
||||
in light mode; it flips above/below, clamps to the viewport, and closes on
|
||||
scroll, resize, pointer-down or `Escape`.
|
||||
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
|
||||
expanding a truncated **number** inside a line of text, but draws the same
|
||||
surface so the two read as one thing.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
+27
-9
@@ -1,15 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
var d =
|
||||
m === "light"
|
||||
? false
|
||||
: m === "system"
|
||||
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
: true;
|
||||
var r = document.documentElement;
|
||||
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>Agent Dashboard - SmartGift Solution</title>
|
||||
<title>AgentHub - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -23,8 +41,8 @@
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Agent Dashboard" />
|
||||
<meta property="og:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta property="og:site_name" content="AgentHub" />
|
||||
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -33,17 +51,17 @@
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/svg+xml" />
|
||||
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
@@ -51,7 +69,7 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Agent Dashboard",
|
||||
"name": "AgentHub",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -13,15 +13,15 @@
|
||||
"categories": ["developer-tools", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
|
||||
+7
-1
@@ -87,13 +87,19 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window" }).then((windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if (client.focus) {
|
||||
if ("focus" in client) {
|
||||
// Reuse an already-open tab instead of stacking a new one — that's
|
||||
// also why `navigate` (same-origin only) beats postMessage: it works
|
||||
// even if the page hasn't wired up a listener for this.
|
||||
if (url && "navigate" in client) client.navigate(url).catch(() => {});
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (url && clients.openWindow) return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useCallback } from "react";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { DocumentTitle } from "./components/DocumentTitle";
|
||||
import { SplashScreen } from "./components/SplashScreen";
|
||||
import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { History } from "./pages/History";
|
||||
import { SessionDetail } from "./pages/SessionDetail";
|
||||
@@ -87,6 +88,9 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
{/* One renderer gives every `title` in the app the designed bubble —
|
||||
see components/Tooltip.tsx for why it is not a wrapper component. */}
|
||||
<GlobalTooltip />
|
||||
<BrowserRouter>
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
|
||||
@@ -182,7 +182,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
|
||||
isMain
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
|
||||
}`}
|
||||
>
|
||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||
|
||||
@@ -174,7 +174,7 @@ export function DateTimePicker({
|
||||
? "bg-accent text-white font-medium"
|
||||
: isToday
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
@@ -201,7 +201,10 @@ export function DateTimePicker({
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
</span>
|
||||
{dateObj && (
|
||||
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
|
||||
<X
|
||||
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
|
||||
onClick={clearValue}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -216,7 +219,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -228,7 +231,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
|
||||
if (value == null) return <span className="text-fg-muted italic">null</span>;
|
||||
if (typeof value === "boolean") {
|
||||
const color = value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
|
||||
|
||||
@@ -625,7 +625,7 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
||||
value={result.backfilled ?? 0}
|
||||
color="text-blue-400"
|
||||
color="text-blue-700 dark:text-blue-400"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.skipped", { count: result.skipped })}
|
||||
@@ -672,12 +672,12 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||
value={backupResult.events}
|
||||
color="text-violet-300"
|
||||
color="text-violet-700 dark:text-violet-300"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
|
||||
value={backupResult.model_pricing}
|
||||
color="text-cyan-300"
|
||||
color="text-cyan-700 dark:text-cyan-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
@@ -737,7 +737,7 @@ function ModeButton({
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
||||
active ? "text-blue-400" : "text-fg-secondary"
|
||||
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -95,21 +95,21 @@ function StatTile({
|
||||
}) {
|
||||
const palette = {
|
||||
default: "border-surface-3 bg-surface-2 text-fg-secondary",
|
||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
|
||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
|
||||
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
|
||||
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
|
||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
|
||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
|
||||
}[tone];
|
||||
|
||||
const iconTone = {
|
||||
default: "text-fg-muted",
|
||||
violet: "text-violet-400",
|
||||
violet: "text-violet-700 dark:text-violet-400",
|
||||
emerald: "text-status-success",
|
||||
amber: "text-status-warning",
|
||||
rose: "text-rose-400",
|
||||
cyan: "text-cyan-400",
|
||||
rose: "text-rose-700 dark:text-rose-400",
|
||||
cyan: "text-cyan-700 dark:text-cyan-400",
|
||||
blue: "text-blue-500",
|
||||
}[tone];
|
||||
|
||||
@@ -359,7 +359,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-400" />
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
Top tools
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
@@ -418,7 +418,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
|
||||
Subagents
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
|
||||
@@ -508,14 +508,14 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
||||
label: "Cache read",
|
||||
value: tokens.cache_read_tokens,
|
||||
cls: "bg-sky-500",
|
||||
text: "text-sky-300",
|
||||
text: "text-sky-700 dark:text-sky-300",
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
label: "Cache write",
|
||||
value: tokens.cache_write_tokens,
|
||||
cls: "bg-violet-500",
|
||||
text: "text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
},
|
||||
{
|
||||
key: "input",
|
||||
@@ -529,7 +529,7 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
||||
label: "Output",
|
||||
value: tokens.output_tokens,
|
||||
cls: "bg-orange-500",
|
||||
text: "text-orange-300",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Workflow,
|
||||
Play,
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Monitor,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -92,6 +92,13 @@ const NAV_KEYS = [
|
||||
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
// owned by the brand, not vendored here — a machine with no network shows the
|
||||
// alt text instead.
|
||||
const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
@@ -171,7 +178,7 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, setTheme, toggleTheme } = useTheme();
|
||||
const { theme, mode, setMode, toggleTheme } = useTheme();
|
||||
// Track whether nav items are clipped by overflow so we can render
|
||||
// chevron affordances pointing toward the hidden items. Recomputed on
|
||||
// scroll, resize, and any structural change (e.g. collapse toggle).
|
||||
@@ -397,15 +404,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Activity className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
|
||||
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{collapsed ? (
|
||||
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
|
||||
{/* Wordmark under the logo: small caps + wide tracking so it reads
|
||||
as a caption to the logo rather than competing with it. The
|
||||
hairlines either side keep it centred on a narrow rail. */}
|
||||
<div className="mt-3 flex w-full items-center gap-2 px-1">
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
|
||||
{t("nav:brand")}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -505,7 +520,9 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:language")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{/* One column per language: a wider grid squeezed each button
|
||||
below the width of its own label once Theme grew a third. */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{SUPPORTED_LANGUAGES.map((language) => {
|
||||
const active = language === currentLanguage;
|
||||
return (
|
||||
@@ -515,7 +532,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:languageNames.${language}`)}
|
||||
title={t(`nav:languageNames.${language}`)}
|
||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
|
||||
className={`rounded-md px-1 py-1.5 text-center text-[11px] font-semibold transition-colors ${
|
||||
active
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -531,13 +548,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:theme")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{(["dark", "light"] as const).map((option) => {
|
||||
const active = option === theme;
|
||||
<div className="mt-2 grid grid-cols-3 gap-1">
|
||||
{(["dark", "light", "system"] as const).map((option) => {
|
||||
const active = option === mode;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setTheme(option)}
|
||||
onClick={() => setMode(option)}
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:themeNames.${option}`)}
|
||||
title={t(`nav:themeNames.${option}`)}
|
||||
@@ -549,8 +566,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
>
|
||||
{option === "dark" ? (
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
) : option === "light" ? (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
||||
cfg.urgent
|
||||
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
|
||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
|
||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
* by {@link StatCard} for raw metric values and anywhere a compact display needs
|
||||
* a full-precision expansion without cluttering the layout.
|
||||
*
|
||||
* ## Look
|
||||
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
|
||||
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
|
||||
* are visibly one thing. It stays cursor-following (and not anchored like the
|
||||
* global one) because it expands a NUMBER inside a line of text, where the
|
||||
* anchor is a few characters wide and the cursor is the real subject.
|
||||
*
|
||||
* ## Portal rendering
|
||||
* Tooltip content is portaled to `document.body` with `position: fixed` so
|
||||
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
|
||||
@@ -56,6 +63,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { TOOLTIP_SURFACE } from "./Tooltip";
|
||||
|
||||
/** Props for {@link Tip}. */
|
||||
interface TipProps {
|
||||
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
|
||||
{children}
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
style={tipStyle}
|
||||
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
|
||||
>
|
||||
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
|
||||
{raw}
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @file Tooltip.tsx
|
||||
* @description The dashboard's tooltip look, and the one renderer that gives it
|
||||
* to every control in the app.
|
||||
*
|
||||
* ## Why a global renderer instead of a wrapper component
|
||||
* The UI already labels its icon-only controls with the native `title`
|
||||
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
|
||||
* touching every one of those call sites to get a designed bubble, and the
|
||||
* next `title=` someone writes would be back to the browser's grey box.
|
||||
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
||||
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
|
||||
* by removing the attribute for as long as the bubble is up, and restores it
|
||||
* on the way out. Nothing else in the app has to know it exists, and a control
|
||||
* keeps its native tooltip (and its accessible name) when JS has not run.
|
||||
* While the title is parked, a control with no other name (icon-only) carries
|
||||
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
|
||||
* bubble goes away with its control if that is removed from the page.
|
||||
*
|
||||
* ## The look
|
||||
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
|
||||
* hard to read and lands over the thing it describes. It flips above/below and
|
||||
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
|
||||
* after a short delay so a cursor crossing a toolbar does not strobe, and is
|
||||
* built from the theme's own tokens so it is legible in light mode too (the
|
||||
* old bubble was a hardcoded near-black).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
|
||||
export const TOOLTIP_SURFACE =
|
||||
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
|
||||
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
|
||||
"whitespace-pre-wrap break-words";
|
||||
|
||||
/** How long the pointer must rest on a control before its tooltip appears. */
|
||||
const SHOW_DELAY_MS = 350;
|
||||
/** Gap between the control and the bubble, and the minimum viewport margin. */
|
||||
const GAP = 8;
|
||||
|
||||
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
|
||||
* `[data-tip]` matches the control whose title is parked, so moving between
|
||||
* its own children still resolves to it instead of re-opening the bubble. */
|
||||
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
|
||||
|
||||
type Anchored = { text: string; rect: DOMRect };
|
||||
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
||||
|
||||
/**
|
||||
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
||||
* designed tooltip; nothing else has to change.
|
||||
*/
|
||||
export function GlobalTooltip() {
|
||||
const [tip, setTip] = useState<Anchored | null>(null);
|
||||
const [pos, setPos] = useState<Placed | null>(null);
|
||||
const bubble = useRef<HTMLDivElement>(null);
|
||||
// The element whose `title` is currently parked in `data-tip`, so it can be
|
||||
// put back even if the pointer never leaves it (a route change, a re-render
|
||||
// that unmounts it, Escape).
|
||||
const parked = useRef<HTMLElement | null>(null);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
// The element whose delay is running, so re-entering it does not restart it.
|
||||
const pending = useRef<HTMLElement | null>(null);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
pending.current = null;
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
// Drop the instance shim `open` installed; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text !== undefined) {
|
||||
// React re-set `title` while it was parked (a poll flipped the hint):
|
||||
// that value is current, the parked copy is stale. React never
|
||||
// re-applies an unchanged prop, so writing the old one back would win
|
||||
// for good.
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
if (el.dataset.tipAria !== undefined) {
|
||||
el.removeAttribute("aria-label");
|
||||
delete el.dataset.tipAria;
|
||||
}
|
||||
parked.current = null;
|
||||
}
|
||||
setTip(null);
|
||||
setPos(null);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(el: HTMLElement, delay: number) => {
|
||||
const text = (el.getAttribute("title") || "").trim();
|
||||
if (!text) return;
|
||||
restore();
|
||||
pending.current = el;
|
||||
timer.current = window.setTimeout(() => {
|
||||
pending.current = null;
|
||||
// Re-read at fire time: the control may have been re-rendered, removed
|
||||
// or had its title changed while the delay was running.
|
||||
if (!el.isConnected) return;
|
||||
const current = (el.getAttribute("title") || "").trim();
|
||||
if (!current) return;
|
||||
el.dataset.tip = current;
|
||||
el.removeAttribute("title");
|
||||
// React removing `title` while it is parked calls removeAttribute on an
|
||||
// attribute that is already gone: no MutationRecord, nothing to see —
|
||||
// and `restore` would write the stale text back for good. So, on THIS
|
||||
// element and only while parked, that call is taken as React's intent:
|
||||
// the parked copy is dropped and the bubble hides. (React DOM calls
|
||||
// `node.removeAttribute`, the instance method; `restore` deletes the
|
||||
// shim.) No global prototype is touched.
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
restore();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
// The title was an icon-only control's ONLY name; keep it named.
|
||||
if (
|
||||
!el.hasAttribute("aria-label") &&
|
||||
!el.hasAttribute("aria-labelledby") &&
|
||||
!el.textContent?.trim()
|
||||
) {
|
||||
el.setAttribute("aria-label", current);
|
||||
el.dataset.tipAria = "";
|
||||
}
|
||||
parked.current = el;
|
||||
setTip({ text: current, rect: el.getBoundingClientRect() });
|
||||
}, delay);
|
||||
},
|
||||
[restore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const target = (e: Event) => {
|
||||
const node = e.target;
|
||||
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
|
||||
};
|
||||
const onOver = (e: PointerEvent) => {
|
||||
const el = target(e);
|
||||
// Already showing or already counting down for this control: a pointer
|
||||
// crossing its children must neither hide nor restart it.
|
||||
if (!el || el === parked.current || el === pending.current) return;
|
||||
open(el, SHOW_DELAY_MS);
|
||||
};
|
||||
const onOut = (e: PointerEvent) => {
|
||||
// Moving between children of the same control must not flicker.
|
||||
const el = target(e);
|
||||
const to = e.relatedTarget;
|
||||
if (el && to instanceof Node && el.contains(to)) return;
|
||||
if (el || parked.current) restore();
|
||||
};
|
||||
// Keyboard users get the same tooltip, immediately — a focus ring that
|
||||
// reveals nothing is the whole reason icon-only buttons are hard to learn.
|
||||
// Only KEYBOARD focus, though: a click focuses too, and without the
|
||||
// `:focus-visible` check it reopened the bubble the pointerdown just
|
||||
// closed, with no delay. Hover keeps its own path.
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
const el = target(e);
|
||||
if (!el || el === parked.current) return;
|
||||
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
|
||||
open(el, 0);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") restore();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerover", onOver);
|
||||
document.addEventListener("pointerout", onOut);
|
||||
document.addEventListener("pointerdown", restore);
|
||||
document.addEventListener("focusin", onFocus);
|
||||
document.addEventListener("focusout", restore);
|
||||
document.addEventListener("keydown", onKey);
|
||||
// A bubble anchored to a rect is wrong the moment its anchor moves — but
|
||||
// only a scroll of the document or of a box containing the anchor moves
|
||||
// it. A log tailing in another panel must not close the bubble.
|
||||
const onScroll = (e: Event) => {
|
||||
const anchor = parked.current || pending.current;
|
||||
const t = e.target;
|
||||
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", restore);
|
||||
window.addEventListener("blur", restore);
|
||||
return () => {
|
||||
document.removeEventListener("pointerover", onOver);
|
||||
document.removeEventListener("pointerout", onOut);
|
||||
document.removeEventListener("pointerdown", restore);
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
document.removeEventListener("focusout", restore);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", restore);
|
||||
window.removeEventListener("blur", restore);
|
||||
restore();
|
||||
};
|
||||
}, [open, restore]);
|
||||
|
||||
// A control unmounted under a still cursor fires no pointerout: without this
|
||||
// its bubble stays up over whatever replaced it. The same goes for a title
|
||||
// re-rendered under it — the bubble would keep saying the old text, so it
|
||||
// hides and `restore` keeps the new attribute.
|
||||
useEffect(() => {
|
||||
if (!tip) return;
|
||||
const watch = new MutationObserver((records) => {
|
||||
const el = parked.current;
|
||||
if (!el) return;
|
||||
if (
|
||||
!el.isConnected ||
|
||||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
|
||||
)
|
||||
restore();
|
||||
});
|
||||
watch.observe(document.body, { childList: true, subtree: true });
|
||||
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
|
||||
return () => watch.disconnect();
|
||||
}, [tip, restore]);
|
||||
|
||||
// Placed after the first paint, when the bubble's real size is known —
|
||||
// measuring it is the only way to flip and clamp without guessing.
|
||||
useEffect(() => {
|
||||
if (!tip || !bubble.current) return;
|
||||
const { width, height } = bubble.current.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = window.innerHeight;
|
||||
const centre = tip.rect.left + tip.rect.width / 2;
|
||||
|
||||
let left = centre - width / 2;
|
||||
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
|
||||
// Below by default; above only when below would run off the screen AND
|
||||
// above actually fits, so a control at the very bottom still gets a bubble.
|
||||
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
|
||||
|
||||
setPos({
|
||||
left,
|
||||
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
|
||||
// The arrow points at the control even after the bubble was clamped.
|
||||
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
|
||||
above,
|
||||
});
|
||||
}, [tip]);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={bubble}
|
||||
role="tooltip"
|
||||
data-testid="global-tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
opacity: pos ? 1 : 0,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
className={TOOLTIP_SURFACE}
|
||||
>
|
||||
{tip.text}
|
||||
{/* Two borders only: a fully bordered diamond draws its other two
|
||||
edges straight across the bubble it is attached to. */}
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
|
||||
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
|
||||
pos?.above ? "border-b border-r" : "border-l border-t"
|
||||
} border-border`}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalTooltip;
|
||||
@@ -306,7 +306,9 @@ export function UpdateNotifier() {
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -432,7 +434,10 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
{/* `whitespace-nowrap` inherits, so one class keeps every label on its
|
||||
own single line; `flex-wrap` then breaks the ROW instead of the
|
||||
words if a translation still outgrows the modal. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkNow}
|
||||
@@ -473,7 +478,10 @@ export function UpdateNotifier() {
|
||||
) : (
|
||||
<Zap className="w-4 h-4" aria-hidden />
|
||||
)}
|
||||
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
|
||||
{/* `restartingShort`, not `restarting`: the full sentence is
|
||||
already in the banner above, and a whole sentence on a button
|
||||
is what forced this footer onto two lines. */}
|
||||
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -19,14 +19,19 @@ function renderSidebar(wsConnected: boolean, collapsed = false) {
|
||||
}
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("should render the brand name", () => {
|
||||
it("should render the brand logo in place of the text subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Agent Dashboard")).toBeInTheDocument();
|
||||
// One wordmark in both themes — no light/dark variant.
|
||||
expect(screen.getByAltText("SmartGift Solution")).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("logo-main.png")
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("SmartGift Solution")).toBeInTheDocument();
|
||||
it("should render the favicon mark when collapsed", () => {
|
||||
renderSidebar(true, true);
|
||||
const mark = screen.getByAltText("SmartGift Solution");
|
||||
expect(mark).toHaveAttribute("src", "https://smartgift.vn/favicon.ico");
|
||||
});
|
||||
|
||||
it("should render all navigation links", () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file Sidebar.theme.test.tsx
|
||||
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
|
||||
function renderSidebar(collapsed = false) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Sidebar theme controls", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
|
||||
renderSidebar();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking System sets the mode and presses only System", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "System" }));
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking Light applies light mode to <html>", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "Light" }));
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem("theme", "light");
|
||||
renderSidebar(true);
|
||||
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
|
||||
expect(localStorage.getItem("theme")).toBe("dark");
|
||||
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @file Tooltip.test.tsx
|
||||
* @description Pins the app-wide tooltip renderer: a control's native `title`
|
||||
* becomes the designed bubble on hover and on keyboard focus, the native
|
||||
* tooltip is suppressed only while the bubble is up and the attribute is put
|
||||
* back afterwards (it is the control's accessible name), and `data-tip-off`
|
||||
* opts out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GlobalTooltip } from "../Tooltip";
|
||||
|
||||
function setup(extra?: React.ReactNode) {
|
||||
return render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Refresh the working copy">refresh</button>
|
||||
{extra}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
|
||||
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
|
||||
// jsdom's :focus-visible is a heuristic over the previous events and leaks
|
||||
// between tests, so the tests say which kind of focus they mean: a spy answers
|
||||
// `:focus-visible` from this flag and passes every other selector through.
|
||||
let keyboardFocus = false;
|
||||
const realMatches = Element.prototype.matches;
|
||||
beforeEach(() => {
|
||||
keyboardFocus = false;
|
||||
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
|
||||
return sel === ":focus-visible"
|
||||
? keyboardFocus && document.activeElement === this
|
||||
: realMatches.call(this, sel);
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const tabTo = (el: HTMLElement) => {
|
||||
keyboardFocus = true;
|
||||
act(() => el.focus());
|
||||
};
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const settle = async () => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
};
|
||||
|
||||
describe("GlobalTooltip", () => {
|
||||
it("shows the bubble after a delay and takes the native title while it is up", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
hover(button);
|
||||
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
});
|
||||
|
||||
it("hides and gives the title back on the way out", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
unhover(button);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("opens immediately on keyboard focus and closes on Escape", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
tabTo(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
|
||||
setup(
|
||||
<button title="Raw value" data-tip-off>
|
||||
opted out
|
||||
</button>
|
||||
);
|
||||
const opted = screen.getByRole("button", { name: "opted out" });
|
||||
|
||||
hover(opted);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(opted).toHaveAttribute("title", "Raw value");
|
||||
});
|
||||
|
||||
it("reads the title through a child element, and ignores an empty one", async () => {
|
||||
setup(
|
||||
<button title="">
|
||||
<span data-testid="blank-child">blank</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
hover(screen.getByTestId("blank-child"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
button.innerHTML = "<span data-testid='deep'>refresh</span>";
|
||||
hover(screen.getByTestId("deep"));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
});
|
||||
it("keeps an icon-only control's accessible name while its title is parked", async () => {
|
||||
setup(
|
||||
<button title="Stage this file">
|
||||
<svg aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const icon = screen.getByRole("button", { name: "Stage this file" });
|
||||
tabTo(icon);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
expect(icon).not.toHaveAttribute("title");
|
||||
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Put back exactly as found: no aria-label left behind.
|
||||
expect(icon).toHaveAttribute("title", "Stage this file");
|
||||
expect(icon).not.toHaveAttribute("aria-label");
|
||||
});
|
||||
|
||||
it("does not flicker while the pointer crosses the control's own children", async () => {
|
||||
setup(
|
||||
<button title="Two parts">
|
||||
<span data-testid="part-a">a</span>
|
||||
<span data-testid="part-b">b</span>
|
||||
</button>
|
||||
);
|
||||
const a = screen.getByTestId("part-a");
|
||||
const b = screen.getByTestId("part-b");
|
||||
hover(a);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
|
||||
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
|
||||
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
|
||||
// Still up, with no fresh delay.
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
});
|
||||
|
||||
it("hides when its control is removed from the page", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Going away">x</button>
|
||||
</>
|
||||
);
|
||||
hover(screen.getByRole("button", { name: "x" }));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// A poll flips the hint while the pointer rests on the control.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Up to date">pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
// The bubble must not keep advertising the old text.
|
||||
expect(screen.queryByText("Pull 2 commits")).toBeNull();
|
||||
|
||||
unhover(button);
|
||||
expect(button).toHaveAttribute("title", "Up to date");
|
||||
});
|
||||
|
||||
it("leaves no title behind when React removed it while the bubble was up", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// React "removes" an attribute that is already parked: no MutationRecord.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button>pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
unhover(button);
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
expect(button).not.toHaveAttribute("data-tip");
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
setup(<button title="Second control">second</button>);
|
||||
// A click is pointer focus: :focus-visible does not match.
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
await user.click(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
tabTo(screen.getByRole("button", { name: "second" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
|
||||
});
|
||||
|
||||
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
|
||||
render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="scroller">
|
||||
<button title="Inside the scroller">inside</button>
|
||||
</div>
|
||||
<div data-testid="elsewhere" />
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "inside" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("elsewhere"));
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("scroller"));
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Inside the scroller");
|
||||
});
|
||||
|
||||
it("hides when the document itself scrolls", async () => {
|
||||
setup();
|
||||
hover(screen.getByRole("button", { name: "refresh" }));
|
||||
await settle();
|
||||
fireEvent.scroll(document);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,11 +12,11 @@ export function ScopeBadge({ scope }: { scope: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const color =
|
||||
scope === "user"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
|
||||
: scope === "project"
|
||||
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||
: scope === "project-local"
|
||||
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
|
||||
? "bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30"
|
||||
: "bg-surface-3 text-fg-secondary border-border";
|
||||
const label =
|
||||
scope === "project-local"
|
||||
|
||||
@@ -320,7 +320,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={linkM[2]!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</a>
|
||||
@@ -337,7 +337,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={urlM[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
>
|
||||
{urlM[0]}
|
||||
</a>
|
||||
@@ -428,7 +428,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
|
||||
<ul
|
||||
key={idx}
|
||||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
|
||||
@@ -82,25 +82,25 @@ const SENDER_STYLES: Record<
|
||||
label: "User",
|
||||
icon: User,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
|
||||
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30",
|
||||
accentBar: "before:bg-blue-600/40",
|
||||
headerText: "text-blue-300",
|
||||
headerText: "text-blue-700 dark:text-blue-300",
|
||||
},
|
||||
assistant: {
|
||||
label: "Assistant",
|
||||
icon: Bot,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30",
|
||||
accentBar: "before:bg-violet-500/40",
|
||||
headerText: "text-violet-200",
|
||||
headerText: "text-violet-800 dark:text-violet-200",
|
||||
},
|
||||
orchestrator: {
|
||||
label: "Main agent",
|
||||
icon: Workflow,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
|
||||
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30",
|
||||
accentBar: "before:bg-teal-500/40",
|
||||
headerText: "text-teal-200",
|
||||
headerText: "text-teal-800 dark:text-teal-200",
|
||||
},
|
||||
system: {
|
||||
label: "System",
|
||||
@@ -169,7 +169,7 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
|
||||
return (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
|
||||
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
|
||||
<Pencil className="w-3 h-3 text-violet-700/70 dark:text-violet-300/70 flex-shrink-0" />
|
||||
<span className="text-fg-muted">Renamed session →</span>
|
||||
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
|
||||
{timestamp && (
|
||||
@@ -253,11 +253,13 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Persisted output"
|
||||
borderClass="border-violet-500/20"
|
||||
bgClass="bg-violet-500/5"
|
||||
textClass="text-violet-300/80"
|
||||
textClass="text-violet-700/80 dark:text-violet-300/80"
|
||||
/>
|
||||
);
|
||||
case "text": {
|
||||
@@ -394,7 +396,9 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||
<span className="text-orange-700/70 dark:text-orange-300/70">
|
||||
↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
@@ -413,11 +417,13 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-cyan-400/60 flex-shrink-0" />}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Task Notification"
|
||||
borderClass="border-cyan-500/20"
|
||||
bgClass="bg-cyan-500/5"
|
||||
textClass="text-cyan-400/80"
|
||||
textClass="text-cyan-700/80 dark:text-cyan-400/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<FileText className="w-3.5 h-3.5 text-violet-400" />
|
||||
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
<span className="font-mono">{obj.file_path}</span>
|
||||
{obj.replace_all === true && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
|
||||
@@ -178,7 +178,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
if (tool === "read" && typeof obj.file_path === "string") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
|
||||
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
|
||||
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
|
||||
<span className="font-mono break-all">{obj.file_path}</span>
|
||||
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
||||
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
|
||||
@@ -198,7 +198,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||
Pattern
|
||||
</span>
|
||||
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{obj.pattern}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,8 @@ export interface ToolStyle {
|
||||
|
||||
const VIOLET: ToolStyle = {
|
||||
Icon: Wrench,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
};
|
||||
@@ -92,99 +92,99 @@ const VIOLET: ToolStyle = {
|
||||
const STYLES: Record<string, ToolStyle> = {
|
||||
bash: {
|
||||
Icon: Terminal,
|
||||
text: "text-emerald-300",
|
||||
chip: "bg-emerald-500/15 text-emerald-300",
|
||||
text: "text-emerald-700 dark:text-emerald-300",
|
||||
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
||||
bar: "bg-emerald-500/60",
|
||||
border: "border-emerald-500/20",
|
||||
},
|
||||
read: {
|
||||
Icon: FileText,
|
||||
text: "text-sky-300",
|
||||
chip: "bg-sky-500/15 text-sky-300",
|
||||
text: "text-sky-700 dark:text-sky-300",
|
||||
chip: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
bar: "bg-sky-500/60",
|
||||
border: "border-sky-500/20",
|
||||
},
|
||||
write: {
|
||||
Icon: FilePlus2,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
},
|
||||
edit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
text: "text-amber-700 dark:text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
multiedit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
text: "text-amber-700 dark:text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
grep: {
|
||||
Icon: Search,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
text: "text-cyan-700 dark:text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
glob: {
|
||||
Icon: FolderTree,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
text: "text-cyan-700 dark:text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
webfetch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
websearch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
task: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
agent: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
todowrite: {
|
||||
Icon: ListTodo,
|
||||
text: "text-rose-300",
|
||||
chip: "bg-rose-500/15 text-rose-300",
|
||||
text: "text-rose-700 dark:text-rose-300",
|
||||
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
|
||||
bar: "bg-rose-500/60",
|
||||
border: "border-rose-500/20",
|
||||
},
|
||||
schedulewakeup: {
|
||||
Icon: Clock,
|
||||
text: "text-orange-300",
|
||||
chip: "bg-orange-500/15 text-orange-300",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
chip: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
|
||||
bar: "bg-orange-500/60",
|
||||
border: "border-orange-500/20",
|
||||
},
|
||||
skill: {
|
||||
Icon: Sparkles,
|
||||
text: "text-fuchsia-300",
|
||||
chip: "bg-fuchsia-500/15 text-fuchsia-300",
|
||||
text: "text-fuchsia-700 dark:text-fuchsia-300",
|
||||
chip: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300",
|
||||
bar: "bg-fuchsia-500/60",
|
||||
border: "border-fuchsia-500/20",
|
||||
},
|
||||
|
||||
@@ -289,7 +289,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
let newLine = hunk.newStart;
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-700 dark:text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
<table className="w-full border-collapse">
|
||||
@@ -301,7 +301,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
const showNew = kind !== "remove";
|
||||
const rowBg =
|
||||
kind === "add"
|
||||
? "bg-green-500/10 text-green-200"
|
||||
? "bg-green-500/10 text-green-800 dark:text-green-200"
|
||||
: kind === "remove"
|
||||
? "bg-status-danger/10 text-status-danger"
|
||||
: "text-fg-secondary";
|
||||
@@ -372,7 +372,7 @@ function ValueCell({ value }: { value: unknown }) {
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
|
||||
value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-fg-secondary border-border-light/30 bg-surface-4/10"
|
||||
}`}
|
||||
>
|
||||
@@ -445,7 +445,7 @@ export function MatchList({ matches }: { matches: GrepMatch[] }) {
|
||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
|
||||
{matches.map((m, i) => (
|
||||
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
|
||||
{m.file && <span className="text-cyan-300">{m.file}</span>}
|
||||
{m.file && <span className="text-cyan-700 dark:text-cyan-300">{m.file}</span>}
|
||||
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
|
||||
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
|
||||
</li>
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* the route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
* auto-setup calls against a cwd that must actually exist on disk first.
|
||||
* An optional "Create/update readme file" checkbox starts the lane's terminal
|
||||
* run right after creation with `/sg-all:sg-md-writing` as its first message.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,8 +24,8 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool } from "../../lib/types";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -79,11 +77,16 @@ export function AddLaneModal({
|
||||
onClose,
|
||||
onAdded,
|
||||
cwdSuggestions,
|
||||
prefill,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the newly created (possibly still-provisioning) lane. */
|
||||
onAdded: (lane: Lane) => void;
|
||||
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
|
||||
* A branch here means the sibling worktree checks out THAT branch, which
|
||||
* is the existing-branch mode — the reason that mode exists (issue #34). */
|
||||
prefill?: { sourceRepo?: string; branch?: string } | null;
|
||||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||||
* home, recently-used paths) — reused rather than fetched a second time. */
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
@@ -94,10 +97,17 @@ export function AddLaneModal({
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
// Two ways to start a worktree lane, and git's own rule decides which is
|
||||
// possible: "new" forks a branch off `base`, "existing" checks out a branch
|
||||
// that is already there — which git allows exactly once across all of a
|
||||
// repo's worktrees, hence the busy markers below.
|
||||
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
|
||||
const [base, setBase] = useState("");
|
||||
const [pipeline, setPipeline] = useState("default");
|
||||
const [writeDocs, setWriteDocs] = useState(false);
|
||||
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
|
||||
const [router, setRouter] = useState("native");
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -107,6 +117,7 @@ export function AddLaneModal({
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
agents: "ok" | "failed";
|
||||
mcp: "ok" | "failed";
|
||||
router: "ok" | "skipped" | "failed";
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
@@ -115,9 +126,11 @@ export function AddLaneModal({
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBranchMode("new");
|
||||
setBase("");
|
||||
setPipeline("default");
|
||||
setWriteDocs(false);
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
@@ -125,22 +138,42 @@ export function AddLaneModal({
|
||||
setSetupResult(null);
|
||||
};
|
||||
|
||||
// The template a lane is created with is the ONLY chance to get it right
|
||||
// from here: nothing else in the UI can change it afterwards, so a lane
|
||||
// silently born on `default` renders an 8-node map for a 16-node workflow.
|
||||
// Fetched on open (templates are file-backed and can change between opens).
|
||||
// A prefill only ever seeds the fields ON OPEN: the user may then change
|
||||
// anything, and re-applying it on every render would fight them for the box.
|
||||
useEffect(() => {
|
||||
if (!open || !prefill) return;
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.branch) {
|
||||
setBranch(prefill.branch);
|
||||
setBranchMode("existing");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// A session that boots on the vendor's API keeps talking to it for its whole
|
||||
// life, so the router has to be picked here, before the lane exists.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
api.lanes
|
||||
.pipelines()
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (!cancelled) setPipelines(r.pipelines);
|
||||
if (cancelled) return;
|
||||
setRouters(r.items);
|
||||
// A profile deleted in another tab while this modal sat open would
|
||||
// otherwise stay selected in state while the select showed no matching
|
||||
// option — and submit would send a name the server no longer knows.
|
||||
setRouter((current) =>
|
||||
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Quiet: the select just falls back to the single `default` option
|
||||
// below, and the lane still gets created.
|
||||
if (!cancelled) setPipelines([]);
|
||||
// Quiet: with no list the radio simply offers Native, and the lane is
|
||||
// still created — a router is an optional extra, never a precondition.
|
||||
if (!cancelled) setRouters([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setRoutersLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -157,6 +190,7 @@ export function AddLaneModal({
|
||||
const path = sourceRepo.trim();
|
||||
if (!path) {
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBase("");
|
||||
setBranchesError(null);
|
||||
return;
|
||||
@@ -167,6 +201,7 @@ export function AddLaneModal({
|
||||
const r = await api.lanes.branches(path);
|
||||
if (lookedUpFor.current !== path) return; // a newer path superseded this one
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
setBase(r.current || r.branches[0] || "");
|
||||
setBranchesError(null);
|
||||
} catch {
|
||||
@@ -174,6 +209,7 @@ export function AddLaneModal({
|
||||
// Not yet a valid repo path (still being typed, or genuinely wrong) -
|
||||
// quiet by design, the same way CwdAutocomplete never errors either.
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBase("");
|
||||
setBranchesError(t("addLaneNotARepo"));
|
||||
}
|
||||
@@ -181,13 +217,9 @@ export function AddLaneModal({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, sourceRepo, t]);
|
||||
|
||||
// The checkbox below runs the docs skill by opening the lane's own terminal
|
||||
// run with the slash command as its first message — a skill only exists
|
||||
// inside a CLI session, so there is nothing server-side to call instead.
|
||||
// Claude-only: codex has no slash commands (same reason LaneConsolePane
|
||||
// prefills prose for a codex lane).
|
||||
const startDocsRun = (laneId: number, cwd: string) =>
|
||||
api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" });
|
||||
// Native is the built-in profile, so this is one call either way — there is
|
||||
// no "no router" branch to keep in step with the real one.
|
||||
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
|
||||
|
||||
const submit = async () => {
|
||||
const repo = sourceRepo.trim();
|
||||
@@ -201,11 +233,40 @@ export function AddLaneModal({
|
||||
const result = await api.lanes.ensure({
|
||||
cwd: repo,
|
||||
title: name || undefined,
|
||||
pipeline,
|
||||
tool,
|
||||
// THIS directory, not a lane on a parent of it (#42).
|
||||
exact: true,
|
||||
});
|
||||
if (writeDocs && tool === "claude") await startDocsRun(result.lane.id, result.lane.cwd);
|
||||
// Report the lane the INSTANT it exists server-side. Everything after
|
||||
// this is post-creation setup that can fail on its own, and letting one
|
||||
// of those failures skip `onAdded` would leave a real lane invisible in
|
||||
// the list until some unrelated refetch happened to pick it up.
|
||||
// Worktree mode below already works this way.
|
||||
onAdded(result.lane);
|
||||
// Nothing was created: closing now reads as success while no new card
|
||||
// appears (#42). Say which lane already owns the folder instead.
|
||||
if (!result.created) {
|
||||
setError(
|
||||
t("addLaneExists", {
|
||||
id: result.lane.id,
|
||||
title: result.lane.title || result.lane.cwd,
|
||||
})
|
||||
);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// A session that booted on the vendor's API would keep talking to it
|
||||
// for its whole life, so the router is applied before anything runs.
|
||||
try {
|
||||
await applyRouter(result.lane.id);
|
||||
} catch (routerErr) {
|
||||
// The lane exists and is already listed — name what did not finish
|
||||
// instead of closing on a half-set-up lane.
|
||||
setError(routerErr instanceof Error ? routerErr.message : String(routerErr));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -215,14 +276,17 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!branches || !name || !branch.trim()) return;
|
||||
// A blank branch in "new" mode is legal and deliberate: the server names
|
||||
// it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews.
|
||||
if (!branches || !name || (branchMode === "existing" && !branch.trim())) return;
|
||||
try {
|
||||
const result = await api.lanes.worktree({
|
||||
sourceRepo: repo,
|
||||
title: name,
|
||||
base: base || undefined,
|
||||
branch: branch.trim(),
|
||||
pipeline,
|
||||
// An existing branch is checked out as it stands; `base` only means
|
||||
// something when a branch is being forked.
|
||||
base: branchMode === "new" ? base || undefined : undefined,
|
||||
branch: branch.trim() || undefined,
|
||||
tool,
|
||||
});
|
||||
onAdded(result.lane);
|
||||
@@ -239,12 +303,13 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
api.lanes.profileInit(result.lane.id),
|
||||
api.lanes.agentsInstall(result.lane.id),
|
||||
api.lanes.mcpSync(result.lane.id),
|
||||
applyRouter(result.lane.id),
|
||||
]);
|
||||
setSetupResult({
|
||||
const outcome = {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled"
|
||||
? profileOutcome.value.scaffolded
|
||||
@@ -253,11 +318,11 @@ export function AddLaneModal({
|
||||
: "failed",
|
||||
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
});
|
||||
|
||||
if (writeDocs && tool === "claude") {
|
||||
await startDocsRun(result.lane.id, provisionedLane.cwd);
|
||||
}
|
||||
router:
|
||||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||||
} as const;
|
||||
const anyFailed = Object.values(outcome).includes("failed");
|
||||
setSetupResult(outcome);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
@@ -265,13 +330,23 @@ export function AddLaneModal({
|
||||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||
// Never the profile itself: it would put an endpoint and a lane id in
|
||||
// the browser console. (The key is never sent here at all.)
|
||||
router: routerOutcome.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Leave the modal open so the setup summary below stays on screen; the
|
||||
// user dismisses it themselves (Cancel/X) once they've seen it, rather
|
||||
// than racing a timer that can close before they've looked at it.
|
||||
// A clean setup has nothing to tell anyone, so the lane is made and the
|
||||
// dialog is done — leaving it up read as "still working" (the confirm
|
||||
// button is dead from here, since the lane already exists) with no
|
||||
// obvious way out. A summary is kept on screen only when a step FAILED,
|
||||
// and then the confirm button becomes a plain close, because that is the
|
||||
// one case where the user has something to read before dismissing it.
|
||||
setBusy(false);
|
||||
if (!anyFailed) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
@@ -296,21 +371,33 @@ export function AddLaneModal({
|
||||
onClose();
|
||||
}, [onClose, browseOpen]);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
// the confirm button has no second creation to run: it closes instead. That
|
||||
// is the difference between a dialog that is finished and one that is stuck.
|
||||
const done = !!setupResult;
|
||||
const disabled =
|
||||
!!setupResult ||
|
||||
!sourceRepo.trim() ||
|
||||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
/** branch name -> the worktree directory already holding it. git allows one
|
||||
* checkout per branch, so these are the branches this form cannot use. */
|
||||
const busyBranches = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("addLane")}
|
||||
confirmLabel={t("add")}
|
||||
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onConfirm={submit}
|
||||
onConfirm={done ? handleCancel : submit}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -373,6 +460,37 @@ export function AddLaneModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
for a branch in the dark: the one fact that decides whether a
|
||||
branch can be used at all is which worktree is holding it. */}
|
||||
{mode === "worktree" && worktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="add-lane-worktrees"
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
>
|
||||
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
|
||||
{t("addLaneWorktreesTitle", { count: worktrees.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{worktrees.slice(0, 8).map((w) => (
|
||||
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
|
||||
<span className="shrink-0 font-mono text-fg-primary">
|
||||
⑂ {w.branch || t("addLaneWorktreeDetached")}
|
||||
</span>
|
||||
<span className="truncate text-fg-muted" title={w.path}>
|
||||
{w.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{worktrees.length > 8 && (
|
||||
<li className="text-[10px] text-fg-muted">
|
||||
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||||
{t("addLaneTitleLabel")}
|
||||
@@ -382,89 +500,213 @@ export function AddLaneModal({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("addLaneTitlePlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
|
||||
{t("addLanePipelineLabel")}
|
||||
</label>
|
||||
<select
|
||||
id="add-lane-pipeline"
|
||||
value={pipeline}
|
||||
onChange={(e) => setPipeline(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
|
||||
(p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||
</div>
|
||||
|
||||
{tool === "claude" && (
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={writeDocs}
|
||||
onChange={(e) => setWriteDocs(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-accent"
|
||||
/>
|
||||
{t("addLaneWriteDocsLabel")}
|
||||
</label>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneWriteDocsHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mode === "worktree" && branchesError && !branches && (
|
||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
|
||||
<div data-testid="add-lane-branch-section" className="space-y-2">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={branchMode === "new"}
|
||||
label={t("addLaneBranchModeNew")}
|
||||
onClick={() => {
|
||||
setBranchMode("new");
|
||||
setBranch("");
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={branchMode === "existing"}
|
||||
label={t("addLaneBranchModeExisting")}
|
||||
onClick={() => {
|
||||
setBranchMode("existing");
|
||||
// Land on something usable rather than on an empty select
|
||||
// that looks like a choice was already made.
|
||||
setBranch((current) =>
|
||||
freeBranches.includes(current) ? current : freeBranches[0] || ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* The third answer to "which branch": none. A small or personal
|
||||
project usually wants the lane to BE the repo, working on
|
||||
whatever is checked out (`main`) — which is exactly Repo
|
||||
mode, so this jumps there rather than inventing a second way
|
||||
to do it. git cannot check `main` out in a second worktree
|
||||
while the repo itself holds it, so a worktree lane could not
|
||||
offer this even if it wanted to. */}
|
||||
<Seg
|
||||
active={false}
|
||||
label={t("addLaneBranchModeCurrent")}
|
||||
title={t("addLaneBranchModeCurrentHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branchMode === "new" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
// Neutral on purpose: an empty field lets the SERVER name
|
||||
// the branch, under a LANE_BRANCH_PREFIX this form cannot
|
||||
// see, so any `feat/…` preview here could be wrong.
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
|
||||
{t("addLaneBranchExistingLabel")}
|
||||
</label>
|
||||
{freeBranches.length === 0 ? (
|
||||
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-existing"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => {
|
||||
const busyAt = busyBranches.get(b);
|
||||
return (
|
||||
// Disabled rather than hidden: "why is my branch not
|
||||
// in the list" is a worse question than seeing it
|
||||
// greyed out with the worktree that holds it.
|
||||
<option key={b} value={b} disabled={!!busyAt}>
|
||||
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The router is the one per-lane extra this form still asks for, so it
|
||||
sits in the form itself — a disclosure around a single field only
|
||||
hid it. The pipeline picker is gone entirely: every lane is created
|
||||
on `default` (the server's own default) and the template is changed
|
||||
from the lane's own header afterwards. */}
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2">
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active =
|
||||
option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
// The input only goes visually hidden, so keyboard and screen
|
||||
// readers still get a real radio group, and the card carries
|
||||
// its focus ring.
|
||||
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
|
||||
: active
|
||||
? "cursor-pointer border-accent bg-accent/10 text-accent"
|
||||
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-lane-router"
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
// Switching to "AI Router" lands on the first saved
|
||||
// profile; with none saved the radio stays on native and
|
||||
// the select below explains why.
|
||||
setRouter(
|
||||
option.value === "native"
|
||||
? "native"
|
||||
: routers.find((r) => !r.builtin)?.name || "native"
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{router !== "native" && (
|
||||
<select
|
||||
aria-label={t("addLaneRouterProfileLabel")}
|
||||
value={router}
|
||||
onChange={(e) => setRouter(e.target.value)}
|
||||
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{routers
|
||||
.filter((r) => !r.builtin)
|
||||
.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{!routersLoaded
|
||||
? t("addLaneRouterLoading")
|
||||
: routers.some((r) => !r.builtin)
|
||||
? t("addLaneRouterHint")
|
||||
: t("addLaneRouterEmptyHint")}
|
||||
</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupResult && (
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||
@@ -477,6 +719,11 @@ export function AddLaneModal({
|
||||
},
|
||||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
{
|
||||
label: t("addLaneSetupRouter"),
|
||||
ok: setupResult.router !== "failed",
|
||||
skipped: setupResult.router === "skipped",
|
||||
},
|
||||
] as const
|
||||
).map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||
|
||||
@@ -123,16 +123,15 @@ export function DestructiveLaneModal({
|
||||
|
||||
const blocked = blockingReason(preflight);
|
||||
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required
|
||||
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting
|
||||
// an adopted lane risks nothing on disk, so it needs no force even with unpushed
|
||||
// commits. Never offered alongside a hard blocker, where confirming is
|
||||
// for `reset` alone. `remove` forgets the lane's row and deletes nothing on
|
||||
// disk — no worktree, no branch, no database — so there is no unpushed work it
|
||||
// could discard. Never offered alongside a hard blocker, where confirming is
|
||||
// impossible anyway.
|
||||
const requiresForce =
|
||||
!blocked &&
|
||||
preflight !== null &&
|
||||
preflight.action !== "purge" &&
|
||||
preflight.blocked.includes("unpushed-commits") &&
|
||||
(preflight.action === "reset" || preflight.kind === "managed");
|
||||
preflight.action === "reset" &&
|
||||
preflight.blocked.includes("unpushed-commits");
|
||||
const disabled =
|
||||
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
|
||||
const facts = preflight ? Object.entries(expectFor(preflight)) : [];
|
||||
|
||||
@@ -99,7 +99,7 @@ export function FolderBrowseModal({
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
|
||||
@@ -1,77 +1,48 @@
|
||||
/**
|
||||
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
|
||||
* @file One lane's card: CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
* it should have been working) is called out loudly — that is the failure
|
||||
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||
* NOT repeated here — this card only ever renders inside the Workspace
|
||||
* lane-detail section, whose header above it already shows them. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
||||
* lane-detail section, whose header above it already shows them. Neither is
|
||||
* progress: the pipeline map directly below this card draws the same number
|
||||
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||||
* second telling of it. The repo
|
||||
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
||||
* only other place one is chosen, and a lane outlives its creation. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file — agents
|
||||
* install / MCP sync / dev-sync check / create-update readme) and the
|
||||
* remove-lane button live in Workspace's page header / pipeline-picker row
|
||||
* instead of here, so they stay reachable and un-clipped regardless of this
|
||||
* card's own scroll area.
|
||||
* card's own scroll area. The working copy — branch, uncommitted counts, the
|
||||
* git action bar — is not here either: it heads the Changes panel
|
||||
* (`LaneGitHeader`), beside the diff it describes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is three git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
||||
* because a stack dying is exactly what the user needs to see, and slower than
|
||||
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
|
||||
* git refresh because a stack dying is exactly what the user needs to see, and slower than
|
||||
* the lane poll because each call opens a socket per declared port. */
|
||||
const RUNTIME_REFRESH_MS = 10_000;
|
||||
|
||||
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
||||
* as the git facts (slow, since lock state changes infrequently). */
|
||||
* as the Changes panel's git facts (slow, since lock state changes infrequently). */
|
||||
const LOCKS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched per card rather than folded into the
|
||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders as a card without a git row.
|
||||
*/
|
||||
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a card that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId]);
|
||||
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's own application stack. Same shape and same silence as the git
|
||||
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* is the common case and not an error worth a banner.
|
||||
*
|
||||
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
||||
@@ -134,38 +105,156 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
return locks;
|
||||
}
|
||||
|
||||
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
||||
/**
|
||||
* Which AI Router this lane runs against, and the one place it can be changed
|
||||
* after the lane exists — the Add-lane modal is the only other one, and a lane
|
||||
* outlives the moment it was created.
|
||||
*
|
||||
* Applying writes the lane's own config on disk as well as the DB column, so it
|
||||
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
|
||||
* the select back: the row must not claim a router the lane did not get.
|
||||
*
|
||||
* Rendered only when there is something to choose. A user who has never opened
|
||||
* the AI Router tab has exactly the built-in `native` profile, and a select
|
||||
* with one option is a control that cannot do anything.
|
||||
*/
|
||||
export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
const [current, setCurrent] = useState(lane.router_profile || "native");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string; runId?: string } | null>(null);
|
||||
|
||||
function useLaneIntegrations(
|
||||
laneId: number,
|
||||
available: boolean
|
||||
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
||||
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
||||
// The polled lane list is the source of truth; a switch elsewhere (the lane
|
||||
// was reset to native by a profile delete, say) has to land here too.
|
||||
useEffect(() => {
|
||||
setCurrent(lane.router_profile || "native");
|
||||
setNote(null);
|
||||
}, [lane.id, lane.router_profile]);
|
||||
|
||||
// One instance of this row serves every lane — it lives in the Workspace's
|
||||
// lane-detail header, and switching lanes swaps its `lane` prop rather than
|
||||
// remounting it. So an apply that is still in flight when the user moves on
|
||||
// would land its rollback and its note on whatever lane is showing by then:
|
||||
// lane B's failed switch putting lane B's old selection into lane A's picker.
|
||||
const shownLane = useRef(lane.id);
|
||||
useEffect(() => {
|
||||
shownLane.current = lane.id;
|
||||
}, [lane.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
||||
.then((results) => {
|
||||
if (!alive) return;
|
||||
const next: Record<string, boolean> = {};
|
||||
INTEGRATION_NAMES.forEach((name, i) => {
|
||||
next[name] = results[i]?.enabled ?? false;
|
||||
});
|
||||
setState(next);
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (alive) setRouters(r.items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setState(null);
|
||||
// Silent, same contract as the runtime poller above: a card that
|
||||
// cannot list routers simply shows no router row.
|
||||
if (alive) setRouters([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [laneId, available]);
|
||||
}, []);
|
||||
|
||||
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
||||
if (!routers || routers.length <= 1) return null;
|
||||
|
||||
const change = async (name: string) => {
|
||||
const laneId = lane.id;
|
||||
const previous = current;
|
||||
setCurrent(name);
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const res = await api.lanes.routerApply(laneId, name);
|
||||
if (shownLane.current !== laneId) return;
|
||||
// A live pane adopted the new base URL/key but kept the model names it
|
||||
// resolved at startup, so it would send the OLD router's model to the NEW
|
||||
// endpoint and fail with "model not found". Only that case gets the
|
||||
// warning and the restart button.
|
||||
setNote(
|
||||
res.restart_required && res.run_id
|
||||
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
|
||||
: { ok: true, text: t("router.applied") }
|
||||
);
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setCurrent(previous);
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
// `busy` belongs to the control on screen, not to the request, so it is
|
||||
// cleared either way — a lane switch must never leave the picker locked.
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Kill the pane and open a fresh one for this lane. Started with the lane id
|
||||
* alone on purpose: the previous run's `--model` flag names a model of the
|
||||
* router the user just left, so replaying it would re-create the exact
|
||||
* mismatch this button exists to clear.
|
||||
*/
|
||||
const restart = async (runId: string) => {
|
||||
const laneId = lane.id;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.run.kill(runId);
|
||||
await api.run.start({ laneId });
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: true, text: t("router.restarted") });
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-xs text-fg-muted"
|
||||
data-testid={`lane-router-${lane.id}`}
|
||||
>
|
||||
{t("router.label")}
|
||||
<select
|
||||
value={current}
|
||||
disabled={busy}
|
||||
onChange={(e) => void change(e.target.value)}
|
||||
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
|
||||
>
|
||||
{routers.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* The applied-note is a tooltip, not a line of its own: it fires on a
|
||||
click the user just made, and a wrapped sentence under the control
|
||||
would push every row below it down for the life of the selection.
|
||||
A failure still gets visible colour, because that one is not
|
||||
self-evident from the select. */}
|
||||
{note && (
|
||||
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
|
||||
{note.ok ? "✓" : "⚠"}
|
||||
</span>
|
||||
)}
|
||||
{/* The one case with an action attached: restarting the pane is what
|
||||
actually makes the switch take effect, so it is a button and not
|
||||
another sentence the user has to act on somewhere else. */}
|
||||
{note?.runId && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void restart(note.runId as string)}
|
||||
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{t("router.restart")}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
@@ -182,7 +271,8 @@ export function since(sec: number | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check) for one lane,
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check, and the
|
||||
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
|
||||
* collapsed into a single dropdown. Lives outside LaneCard so it can be
|
||||
* placed in the Workspace page header, next to "Add lane" — it polls its
|
||||
* own runtime state independently of LaneCard's, same pattern as the
|
||||
@@ -191,14 +281,14 @@ export function since(sec: number | null): string {
|
||||
export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
||||
null
|
||||
);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<
|
||||
"agents" | "mcp" | "sync-check" | "docs" | null
|
||||
>(null);
|
||||
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||
const runtime = useLaneRuntime(lane.id, 0);
|
||||
|
||||
const runLaneAction = async (
|
||||
which: "agents" | "mcp" | "sync-check",
|
||||
which: "agents" | "mcp" | "sync-check" | "docs",
|
||||
fn: () => Promise<string>
|
||||
) => {
|
||||
setLaneActionBusy(which);
|
||||
@@ -218,6 +308,19 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
||||
});
|
||||
|
||||
// A skill only exists inside a CLI session, so there is nothing server-side
|
||||
// to call: this opens the lane's own terminal run with the slash command as
|
||||
// its first message. Claude-only — codex has no slash commands.
|
||||
const handleWriteDocs = () =>
|
||||
runLaneAction("docs", async () => {
|
||||
await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
});
|
||||
return t("actions.writeDocsResult");
|
||||
});
|
||||
|
||||
const handleMcpSync = () =>
|
||||
runLaneAction("mcp", async () => {
|
||||
const result = await api.lanes.mcpSync(lane.id);
|
||||
@@ -273,6 +376,22 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
>
|
||||
{t("actions.agentsInstall")}
|
||||
</button>
|
||||
{lane.tool !== "codex" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-write-docs"
|
||||
title={t("actions.writeDocsHint")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setToolsMenuOpen(false);
|
||||
void handleWriteDocs();
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("actions.writeDocs")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -333,10 +452,8 @@ export default function LaneCard({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
|
||||
|
||||
/**
|
||||
* Live boot feedback. A build can run for minutes, and a card showing only a
|
||||
@@ -363,25 +480,10 @@ export default function LaneCard({
|
||||
<>
|
||||
<div
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||
// Every row below is conditional: a lane with nothing to report
|
||||
// renders no frame at all rather than an empty box.
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
|
||||
>
|
||||
{/* Progress line: how far through the pipeline. The stage's own name
|
||||
and how long it's been running are already in the Workspace
|
||||
header above; not repeated here. Omitted at 0% — an empty bar
|
||||
is not information, just a blank row above a dead/idle lane. */}
|
||||
{lane.progress > 0 && (
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs">
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
data-testid="lane-progress-fill"
|
||||
className="h-full rounded-full bg-blue-600 transition-[width]"
|
||||
style={{ width: `${lane.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{lane.ci_status && (
|
||||
@@ -397,11 +499,8 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lane.needs_action && (
|
||||
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
|
||||
⚠ {lane.needs_action}
|
||||
</div>
|
||||
)}
|
||||
{/* No needs-you banner here: the strip card already flags it, and the
|
||||
console directly below is where the prompt actually waits. */}
|
||||
|
||||
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
||||
declare a profile. A port that drifted from `base + slot` is called
|
||||
@@ -477,31 +576,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mb-2 space-y-0.5 font-mono text-[11px] text-fg-secondary">
|
||||
{git?.available && (
|
||||
<div data-testid="lane-git" className="space-y-1">
|
||||
<div className="truncate">
|
||||
⑂ {git.branch}
|
||||
<span className="ml-2 text-fg-muted">{git.head}</span>
|
||||
</div>
|
||||
<div className="truncate text-fg-muted" title={git.subject}>
|
||||
{git.subject}
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<div className="text-status-warning/80">
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
||||
<div className="truncate text-fg-muted" title={lane.cwd}>
|
||||
{lane.cwd}
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{childWorktrees && childWorktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="lane-child-worktrees"
|
||||
@@ -516,7 +590,7 @@ export default function LaneCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLane?.(w.id)}
|
||||
className="truncate text-left text-blue-400 hover:underline"
|
||||
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
|
||||
title={w.cwd}
|
||||
>
|
||||
#{w.id} {w.title || w.cwd} · {w.status}
|
||||
@@ -527,24 +601,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{integrations && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
|
||||
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
data-testid={`lane-integration-${name}`}
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
integrations[name]
|
||||
? "bg-status-success/10 text-status-success"
|
||||
: "bg-surface-2 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`integrations.${name}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||
in one grid row still line their buttons up. Repo tooling and the
|
||||
remove button live in the Workspace page header/pipeline row now —
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* @file The diff renderer behind the Changes panel (issue #35): one file's
|
||||
* hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted,
|
||||
* with the changed span inside a modified line picked out.
|
||||
*
|
||||
* Three deliberate choices:
|
||||
* - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation
|
||||
* view already ships. No new dependency for a panel that renders a few
|
||||
* hundred lines.
|
||||
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
||||
* right for the case that matters (one edited span on a line), degrades to
|
||||
* "the whole line changed" otherwise, and costs nothing.
|
||||
* - Line pairing for split view happens here, on the rows the server already
|
||||
* numbered. The server stays the single source of what changed; this file
|
||||
* only decides how it looks.
|
||||
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
||||
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||
* (`visibleRange`); the content's min-width comes from the longest line, so
|
||||
* the horizontal extent does not jump as rows come and go.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
|
||||
export type DiffLayout = "unified" | "split";
|
||||
|
||||
/**
|
||||
* How tall a diff box may be. `fill` (the single-file view) takes the height
|
||||
* its parent gives it. In the review list, which stacks many files, a diff
|
||||
* grows to its full height so the PANEL is the one vertical scroller — a
|
||||
* 60vh box per file nested inside it made hunk navigation scroll two boxes
|
||||
* at once and land nowhere predictable. Only a windowed diff (above
|
||||
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
|
||||
* scroller.
|
||||
*/
|
||||
function boxHeight(fill: boolean, windowed: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `el` to the top of the scroller that actually scrolls it, below
|
||||
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
|
||||
* ancestor, the page included.
|
||||
*/
|
||||
function scrollToTop(el: HTMLElement, pad: number) {
|
||||
let s = el.parentElement;
|
||||
while (
|
||||
s &&
|
||||
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
|
||||
)
|
||||
s = s.parentElement;
|
||||
if (!s) return;
|
||||
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
|
||||
}
|
||||
|
||||
/** Rows above which a view windows; at or below it renders exactly as before. */
|
||||
export const WINDOW_THRESHOLD = 500;
|
||||
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
* is clamped to the content first, so a stale offset (content that shrank, a
|
||||
* layout switch) still lands on real rows instead of an empty window.
|
||||
*/
|
||||
export function visibleRange(
|
||||
scrollTop: number,
|
||||
viewport: number,
|
||||
rowHeight: number,
|
||||
count: number,
|
||||
overscan: number
|
||||
) {
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
|
||||
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
|
||||
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
|
||||
return { start: Math.min(start, end), end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowing state for one scroller of `count` items. Off (the whole range)
|
||||
* at or below the threshold.
|
||||
*/
|
||||
function useWindow(count: number) {
|
||||
const [top, setTop] = useState(0);
|
||||
const on = count > WINDOW_THRESHOLD;
|
||||
// The offset is tracked even while windowing is off, so a diff that GROWS
|
||||
// past the threshold (expanded context) windows where the reader is, not at
|
||||
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
|
||||
const lastTop = useRef(0);
|
||||
// count 0 = no scroller here (split layout): the browser drops its offset,
|
||||
// so coming back to unified must start from 0, not the pre-split row.
|
||||
if (count === 0) lastTop.current = 0;
|
||||
const [wasOn, setWasOn] = useState(on);
|
||||
if (wasOn !== on) {
|
||||
setWasOn(on);
|
||||
setTop(on ? lastTop.current : 0);
|
||||
}
|
||||
// ponytail: the viewport is taken as the window height — never smaller than
|
||||
// a box that lives inside the window, so it only over-renders a few dozen
|
||||
// rows, and it needs no ResizeObserver to follow a panel resize.
|
||||
const range = on
|
||||
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
|
||||
: { start: 0, end: count };
|
||||
const onScroll = (el: HTMLElement) => {
|
||||
lastTop.current = el.scrollTop;
|
||||
if (on) setTop(el.scrollTop);
|
||||
};
|
||||
return { on, range, onScroll };
|
||||
}
|
||||
|
||||
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
|
||||
function longest(texts: Iterable<string>) {
|
||||
let max = 0;
|
||||
for (const t of texts) {
|
||||
let n = t.length;
|
||||
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
|
||||
if (n > max) max = n;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Stands in for `rows` off-screen rows. */
|
||||
function Spacer({ rows }: { rows: number }) {
|
||||
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
|
||||
}
|
||||
|
||||
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
|
||||
function Windowed({
|
||||
count,
|
||||
range,
|
||||
minWidth,
|
||||
children,
|
||||
}: {
|
||||
count: number;
|
||||
range: { start: number; end: number };
|
||||
minWidth: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div data-diff-content style={{ minWidth }}>
|
||||
<Spacer rows={range.start} />
|
||||
{children}
|
||||
<Spacer rows={count - range.end} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
function langFor(filePath: string) {
|
||||
const ext = filePath.split(".").pop() || "";
|
||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-fg-primary/15">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{highlight(text, lang).map((tok, i) => (
|
||||
<span key={i} className={tokenClass(tok.type)}>
|
||||
{tok.text}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The changed span of a del/add pair, as `[start, end]` offsets into each
|
||||
* side, or null when the lines share nothing worth pointing at.
|
||||
*/
|
||||
function emphasis(
|
||||
del: string,
|
||||
add: string
|
||||
): { del: [number, number]; add: [number, number] } | null {
|
||||
if (del === add) return null;
|
||||
let start = 0;
|
||||
const max = Math.min(del.length, add.length);
|
||||
while (start < max && del[start] === add[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
||||
// Nothing in common at either edge: the whole line is the change, and
|
||||
// highlighting all of it adds noise on top of the row tint.
|
||||
if (start === 0 && end === 0) return null;
|
||||
return {
|
||||
del: [start, del.length - end],
|
||||
add: [start, add.length - end],
|
||||
};
|
||||
}
|
||||
|
||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
||||
* index in `hunk.lines` (what the unified view renders by). */
|
||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
||||
|
||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
||||
// columns and every re-render of the same data share one pass. A refetch
|
||||
// brings new objects and the old entries go with them.
|
||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it,
|
||||
* carrying indices so emphasis lands without searching the hunk again. */
|
||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
const hit = analysed.get(hunk);
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const rows: PairRow[] = [];
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
rows.push({ left: line, right: line, emph: null });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const delStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "del") i += 1;
|
||||
const addStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "add") i += 1;
|
||||
const dels = addStart - delStart;
|
||||
const adds = i - addStart;
|
||||
for (let k = 0; k < Math.max(dels, adds); k += 1) {
|
||||
const left = k < dels ? lines[delStart + k]! : null;
|
||||
const right = k < adds ? lines[addStart + k]! : null;
|
||||
const e = left && right ? emphasis(left.text, right.text) : null;
|
||||
if (e) {
|
||||
emph.set(delStart + k, e.del);
|
||||
emph.set(addStart + k, e.add);
|
||||
}
|
||||
rows.push({ left, right, emph: e });
|
||||
}
|
||||
}
|
||||
const out = { rows, emph };
|
||||
analysed.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function Gutter({ n }: { n: number | null }) {
|
||||
return (
|
||||
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
tint: string;
|
||||
style?: CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not). */
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
if (type === "add")
|
||||
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
|
||||
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
|
||||
return "";
|
||||
}
|
||||
|
||||
function markerClass(type: LaneDiffLine["type"]) {
|
||||
if (type === "add") return "font-bold text-status-success";
|
||||
if (type === "del") return "font-bold text-status-danger";
|
||||
return "text-fg-muted";
|
||||
}
|
||||
|
||||
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
|
||||
* last changed line. A context rather than a prop because lines render from
|
||||
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (hunk: LaneDiffHunk) => ReactNode;
|
||||
hunks: LaneDiffHunk[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
|
||||
function lastChangedLine(hunk: LaneDiffHunk) {
|
||||
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
|
||||
if (hunk.lines[i]!.type !== "ctx") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function lastChangedRow(hunk: LaneDiffHunk) {
|
||||
const rows = analyseHunk(hunk).rows;
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const r = rows[i]!;
|
||||
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The review bar: hangs just BELOW the last changed line, pinned to the
|
||||
* visible right edge, so it sits on the change it acts on — the editor-style
|
||||
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
|
||||
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
const index = ctx.hunks.indexOf(hunk);
|
||||
const total = ctx.hunks.length;
|
||||
const nav =
|
||||
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
|
||||
return (
|
||||
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
|
||||
<span
|
||||
data-testid="lane-hunk-bar"
|
||||
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.prevHunk")}
|
||||
title={t("changes.prevHunk")}
|
||||
disabled={index <= 0}
|
||||
onClick={() => ctx.go(index - 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.nextHunk")}
|
||||
title={t("changes.nextHunk")}
|
||||
disabled={index >= total - 1}
|
||||
onClick={() => ctx.go(index + 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="px-0.5 tabular-nums text-fg-muted">
|
||||
{t("changes.hunkOf", { n: index + 1, total })}
|
||||
</span>
|
||||
{ctx.actions(hunk)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
|
||||
const ctx = useContext(HunkActions);
|
||||
const at = hunk.header.split("@@")[1];
|
||||
// Windowed, the header is one ROW_PX row like the lines around it.
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
|
||||
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
|
||||
}`}
|
||||
style={fixed ? FIXED : undefined}
|
||||
data-diff-item={fixed ? "" : undefined}
|
||||
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedLine({
|
||||
line,
|
||||
lang,
|
||||
emph,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
line: LaneDiffLine;
|
||||
lang: string;
|
||||
emph?: [number, number];
|
||||
style?: CSSProperties;
|
||||
/** This is the hunk's last changed line: hang the review bar under it. */
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||||
const { emph } = analyseHunk(hunk);
|
||||
const last = lastChangedLine(hunk);
|
||||
return (
|
||||
<div>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{hunk.lines.map((line, i) => (
|
||||
<UnifiedLine
|
||||
key={i}
|
||||
line={line}
|
||||
lang={lang}
|
||||
emph={emph.get(i)}
|
||||
bar={i === last ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
|
||||
* unified, a paired row in split. */
|
||||
type Item = { hunk: LaneDiffHunk; i: number };
|
||||
|
||||
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
|
||||
const out: Item[] = [];
|
||||
for (const hunk of hunks) {
|
||||
out.push({ hunk, i: -1 });
|
||||
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
|
||||
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function longestInHunks(hunks: LaneDiffHunk[]) {
|
||||
return longest(
|
||||
(function* () {
|
||||
for (const h of hunks) for (const l of h.lines) yield l.text;
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
function SplitLine({
|
||||
row,
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
row: PairRow;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
text={line.text}
|
||||
lang={lang}
|
||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
/** One side of the split view: a hunk header, then that side's rows. */
|
||||
function SplitColumn({
|
||||
hunks,
|
||||
lang,
|
||||
side,
|
||||
}: {
|
||||
hunks: LaneDiffHunk[];
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per hunk: on the right (new) column only.
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A windowed split column: the shared range of items, one side of each. */
|
||||
function SplitSlice({
|
||||
items,
|
||||
range,
|
||||
lang,
|
||||
side,
|
||||
}: {
|
||||
items: Item[];
|
||||
range: { start: number; end: number };
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items
|
||||
.slice(range.start, range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
row={analyseHunk(hunk).rows[i]!}
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `v` held inside a scroller's own extent. */
|
||||
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
return Math.min(v, Math.max(0, scrollSize - clientSize));
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["scrollTop", "scrollHeight", "clientHeight"],
|
||||
];
|
||||
|
||||
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
|
||||
function follow(src: HTMLElement, dst: HTMLElement) {
|
||||
for (const [pos, size, client] of AXES) {
|
||||
// `src` pinned at its own end while `dst` sits past it: `src` is the
|
||||
// narrower column echoing a clamp we just caused. Pulling `dst` back to
|
||||
// it would snap the wide column home every time it scrolls past the
|
||||
// narrow one's longest line.
|
||||
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
|
||||
const target = clampScroll(src[pos], dst[size], dst[client]);
|
||||
if (dst[pos] !== target) dst[pos] = target;
|
||||
}
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||
const win = useWindow(items.length);
|
||||
|
||||
// Mirroring fires the other pane's own scroll handler, which mirrors back —
|
||||
// the equality check (and `follow`'s edge rule) is what ends that, so no
|
||||
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
|
||||
// vertical drift puts a deletion beside some unrelated line. Both columns
|
||||
// window off the same offset, so they always hold the same row range.
|
||||
const sync = (from: "left" | "right") => {
|
||||
const src = from === "left" ? left.current : right.current;
|
||||
const dst = from === "left" ? right.current : left.current;
|
||||
if (!src || !dst) return;
|
||||
follow(src, dst);
|
||||
win.onScroll(src);
|
||||
};
|
||||
|
||||
const column = (side: "left" | "right") =>
|
||||
win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
|
||||
</Windowed>
|
||||
) : (
|
||||
<SplitColumn hunks={hunks} lang={lang} side={side} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${boxHeight(fill, win.on)}`}>
|
||||
<div
|
||||
ref={left}
|
||||
data-testid="lane-diff-split-left"
|
||||
onScroll={() => sync("left")}
|
||||
className="w-1/2 min-w-0 overflow-auto"
|
||||
>
|
||||
{column("left")}
|
||||
</div>
|
||||
<div
|
||||
ref={right}
|
||||
data-testid="lane-diff-split-right"
|
||||
onScroll={() => sync("right")}
|
||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||||
>
|
||||
{column("right")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One file's diff, in the chosen layout. */
|
||||
export function LaneDiffView({
|
||||
file,
|
||||
layout,
|
||||
fill = false,
|
||||
hunkActions,
|
||||
}: {
|
||||
file: LaneChangeFile;
|
||||
layout: DiffLayout;
|
||||
/** Take the parent's height (single-file view) instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Controls drawn in each hunk's review bar. */
|
||||
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(file.path);
|
||||
const hunks = file.hunks;
|
||||
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
||||
const minWidth = useMemo(
|
||||
// two gutters + the row's px-2, plus the marker column
|
||||
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
|
||||
[hunks]
|
||||
);
|
||||
// Unified only; split windows inside its own two scrollers.
|
||||
const win = useWindow(layout === "unified" ? items.length : 0);
|
||||
const splitWindowed = useMemo(
|
||||
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
|
||||
[layout, hunks]
|
||||
);
|
||||
|
||||
// The bar's arrows: bring hunk `index`'s header into view. A windowed
|
||||
// unified diff may not have it in the DOM, so there it is found by offset.
|
||||
const go = (index: number) => {
|
||||
const el = root.current;
|
||||
if (!el || !hunks) return;
|
||||
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
|
||||
// The review list pins each file's header over its own diff.
|
||||
const sticky = el
|
||||
.closest("[data-review-file]")
|
||||
?.querySelector<HTMLElement>("[data-sticky-top]");
|
||||
const pad = sticky?.offsetHeight ?? 0;
|
||||
if (header) {
|
||||
scrollToTop(header, pad);
|
||||
return;
|
||||
}
|
||||
// Windowed: the header is not in the DOM, but its offset is known.
|
||||
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
if (!file.hunks || file.hunks.length === 0)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={i === lastChangedLine(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
</HunkActions.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole file's text (no diff), for the viewer's "read the file" mode. */
|
||||
export function LaneFileView({
|
||||
path,
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
const win = useWindow(lines.length);
|
||||
// gutter + the row's px-2
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
{lines
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map((text, k) => row(text, win.range.start + k, FIXED))}
|
||||
</Windowed>
|
||||
) : (
|
||||
lines.map((text, i) => row(text, i))
|
||||
)}
|
||||
{truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneDiffView;
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, and create/delete a branch. Committing is
|
||||
* NOT here: it is the Changes panel's staged-only commit box, beside the
|
||||
* per-file stage/unstage/discard it commits — one way to commit, and it only
|
||||
* ever takes what the user staged.
|
||||
*
|
||||
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete";
|
||||
|
||||
function Btn({
|
||||
label,
|
||||
icon,
|
||||
busy,
|
||||
disabled,
|
||||
title,
|
||||
compact,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
|
||||
compact?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || busy}
|
||||
title={title}
|
||||
aria-label={compact ? label : undefined}
|
||||
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{!compact && label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneGitBar({
|
||||
laneId,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
lead,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
lead?: React.ReactNode;
|
||||
/** Called after any action that could have moved the working copy. */
|
||||
onChanged: () => void;
|
||||
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
|
||||
* a branch this bar just created. Omitted where no such flow is reachable. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState<Action | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
// render the moment the id changes, before anything can act on it.
|
||||
const [shownLane, setShownLane] = useState(laneId);
|
||||
// One action at a time, checked synchronously: `busy` is state, and a second
|
||||
// Enter can land before the render that disables anything.
|
||||
const inFlight = useRef(false);
|
||||
if (shownLane !== laneId) {
|
||||
setShownLane(laneId);
|
||||
setBusy(null);
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
laneNow.current = laneId;
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
|
||||
const run = async (action: Action, body?: { branch?: string }) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
// The branch list this bar is showing is stale the moment a branch is
|
||||
// created, deleted or switched to, so it is re-read rather than patched.
|
||||
if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
if (laneNow.current === lane)
|
||||
setError(
|
||||
(err as { code?: string })?.code === "ENOREMOTE"
|
||||
? t("gitBar.errNoRemote")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
} finally {
|
||||
if (laneNow.current === lane) {
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(laneId);
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
}
|
||||
};
|
||||
|
||||
const openSwitch = async () => {
|
||||
setSwitching((v) => !v);
|
||||
setError(null);
|
||||
if (branches) return;
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="lane-git-bar" className="space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{lead}
|
||||
{/* Ahead/behind first: it is the fact that decides which of the
|
||||
buttons beside it the user actually wants. */}
|
||||
{/* Null counts with an upstream named means git could not compare
|
||||
the two (the upstream ref is gone): claiming "in sync" there
|
||||
would be a lie, so it reads as not pushed. */}
|
||||
{git.upstream && git.ahead != null && git.behind != null ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||
>
|
||||
{(git.ahead ?? 0) > 0 && (
|
||||
<span className="flex items-center text-status-warning">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{git.ahead}
|
||||
</span>
|
||||
)}
|
||||
{(git.behind ?? 0) > 0 && (
|
||||
<span className="flex items-center text-accent">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
{git.behind}
|
||||
</span>
|
||||
)}
|
||||
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
|
||||
</span>
|
||||
) : (
|
||||
git.hasRemote && (
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noUpstream")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pushHint")}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
// A branch git already has checked out somewhere else cannot be
|
||||
// switched to — git allows one worktree per branch.
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
// A branch any worktree holds — this lane's own included — is
|
||||
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
|
||||
title={
|
||||
picked && (heldBy.has(picked) || picked === git.branch)
|
||||
? t("gitBar.newLaneHeld", {
|
||||
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() => void run("branch-delete", { branch: picked })}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
aria-label={t("gitBar.branchNewPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreate")}
|
||||
icon={<Plus className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// Creating does not switch: the point is usually a sibling lane,
|
||||
// and moving this pane's HEAD under a working agent is not.
|
||||
title={t("gitBar.branchCreateHint")}
|
||||
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
data-testid="lane-git-bar-error"
|
||||
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
|
||||
>
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneGitBar;
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts that open a per-file hover panel)
|
||||
* and the git action bar under it (`LaneGitBar`).
|
||||
*
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
* purpose: each call is five git subprocesses server-side, and a branch name
|
||||
* does not change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched by the Changes panel rather than folded
|
||||
* into the polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders with no git header.
|
||||
*/
|
||||
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
// this row, and waiting out the 30s poll makes the button look inert.
|
||||
const [nonce, setNonce] = useState(0);
|
||||
// A lane switch drops the previous lane's facts during render, so nothing
|
||||
// (the git bar's "current branch" included) shows them until the new read.
|
||||
const [factsLane, setFactsLane] = useState(laneId);
|
||||
if (factsLane !== laneId) {
|
||||
setFactsLane(laneId);
|
||||
setFacts(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a panel that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`: the Changes panel
|
||||
* scrolls in its own `overflow` box, and an absolutely positioned panel would
|
||||
* be clipped by it. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
/** See `LaneGitBar`'s prop of the same name. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
|
||||
if (!git?.available) return null;
|
||||
const lead = (
|
||||
<>
|
||||
{/* One row with the actions: branch first and strongest, then the head
|
||||
and its subject, which is what truncates when the panel is narrow
|
||||
(the whole of it stays in the tooltip). */}
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-git-header"
|
||||
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
|
||||
>
|
||||
<LaneGitBar
|
||||
laneId={laneId}
|
||||
git={git}
|
||||
onChanged={onChanged}
|
||||
onNewLane={onNewLane}
|
||||
lead={lead}
|
||||
/>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* @file The compact lane tile used in the Workspace's horizontal lane strip.
|
||||
* It carries only what you need to pick a lane — which lane, is it alive,
|
||||
* what stage, how far — because the full card, its controls and its
|
||||
* working-copy facts live in the detail panel below it. Keeping the tile
|
||||
* small and fixed-width is what lets many lanes stay scannable in one
|
||||
* scrolling row.
|
||||
* what stage — because the full card, its controls and its working-copy facts
|
||||
* live in the detail panel below it. Stage badges ride on the id row rather
|
||||
* than a line of their own, and progress (bar and percent) is deliberately
|
||||
* absent: the detail panel's pipeline map below shows the same thing with the
|
||||
* stage names attached. Keeping the tile small and fixed-width is what lets
|
||||
* many lanes stay scannable in one scrolling row.
|
||||
*
|
||||
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
|
||||
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
|
||||
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
|
||||
* the keyboard (and touch-free) path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
@@ -43,46 +51,70 @@ function detectionLeads(lane: Lane): boolean {
|
||||
|
||||
export default function LaneStripCard({
|
||||
lane,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
onReorder,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Position in the lane array — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`lane-tile-${lane.id}`}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
data-drop-target={dropTarget ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
title={lane.cwd}
|
||||
className={`w-56 shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(false);
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("reorderHint")}`}
|
||||
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
|
||||
dropTarget
|
||||
? "border-accent bg-accent/20"
|
||||
: selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
{t("cardId", { id: lane.id })}
|
||||
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
||||
width and said nothing, but the number itself is how the CLI
|
||||
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||
#{lane.id}
|
||||
</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
@@ -100,16 +132,16 @@ export default function LaneStripCard({
|
||||
{lane.detected_stage}
|
||||
</span>
|
||||
)}
|
||||
{lane.progress > 0 && (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lane.progress > 0 && (
|
||||
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -107,18 +109,6 @@ async function fillWorktreeForm(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.pipelines)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
pipelines: [
|
||||
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||
{
|
||||
id: "ship-feature",
|
||||
name: "Ship feature (lane pipeline)",
|
||||
nodes: new Array(16).fill({ id: "n" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
@@ -132,10 +122,149 @@ beforeEach(() => {
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: null,
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
profile: "native",
|
||||
});
|
||||
// Only the built-in profile by default, so the router radio stays on Native
|
||||
// and no test has to opt out of it.
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [nativeRouter()] });
|
||||
});
|
||||
|
||||
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
|
||||
function nativeRouter() {
|
||||
return {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved router profile, for the tests that need the radio to have a target. */
|
||||
function savedRouter(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...nativeRouter(),
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
hasKey: true,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
const withWorktrees = {
|
||||
branches: ["main", "feat/other", "feat/held"],
|
||||
current: "main",
|
||||
worktrees: [
|
||||
{ path: "/Users/tester/projects/repo", branch: "main" },
|
||||
{ path: "/lanes/repo__held", branch: "feat/held" },
|
||||
],
|
||||
};
|
||||
|
||||
it("lists the repo's existing worktrees once the path resolves", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
|
||||
const panel = await screen.findByTestId("add-lane-worktrees");
|
||||
expect(panel).toHaveTextContent("/lanes/repo__held");
|
||||
expect(panel).toHaveTextContent("feat/held");
|
||||
});
|
||||
|
||||
it("checks an existing branch out, sending no base", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "Reuse a branch");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
const select = await screen.findByLabelText("Branch to check out");
|
||||
await user.selectOptions(select, "feat/other");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: "feat/other", base: undefined })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("greys out a branch another worktree already holds", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
|
||||
const held = await screen.findByRole("option", { name: /feat\/held/ });
|
||||
expect(held).toBeDisabled();
|
||||
expect(held).toHaveTextContent("/lanes/repo__held");
|
||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("lets the server name a new branch when the field is left empty", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "Auto named");
|
||||
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
|
||||
// the form cannot see, so the placeholder only says the field is optional.
|
||||
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Automatic — named from the title"
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: undefined, base: "main" })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||
// The branch NAME is no longer part of this list: an empty one means "let
|
||||
// the server name it" (issue #34).
|
||||
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
@@ -144,6 +273,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main", "feat/other"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
@@ -176,7 +306,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -192,20 +326,24 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
branch: "feat/new-feature",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
});
|
||||
});
|
||||
expect(onAdded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||
);
|
||||
// The modal stays open showing the setup summary until dismissed - it
|
||||
// does not close itself just because the lane was added.
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
// Setup came back clean, so there is nothing to report and the dialog is
|
||||
// done. Leaving it up read as "still working" — its confirm button is dead
|
||||
// once the lane exists — which is how it looked stuck.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
@@ -224,7 +362,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -243,7 +385,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("still calls onAdded even when every setup call fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -262,11 +408,16 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the setup summary and lets the user dismiss it manually", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||
});
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
@@ -274,17 +425,22 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
await fillWorktreeForm(user, { title: "demo3" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
// A failure is the one case worth reading before the dialog goes away.
|
||||
expect(await screen.findByText("Setup")).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
|
||||
if (!dismissButton) throw new Error("Cancel button not found");
|
||||
await user.click(dismissButton);
|
||||
// And then the primary button must close, not sit there disabled: the lane
|
||||
// already exists, so "Add lane" has nothing left to do.
|
||||
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -347,8 +503,8 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
title: "main repo",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
exact: true,
|
||||
})
|
||||
);
|
||||
expect(api.lanes.worktree).not.toHaveBeenCalled();
|
||||
@@ -357,6 +513,28 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
// close right away like the old adopt flow did.
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the folder already has a lane instead of closing silently (#42)", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 3, kind: "adopted", status: "idle", title: "SG - Services" }),
|
||||
created: false,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument();
|
||||
// The existing lane is selected so the user sees it, but nothing claims a new one.
|
||||
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — folder browse", () => {
|
||||
@@ -400,10 +578,18 @@ describe("AddLaneModal — folder browse", () => {
|
||||
});
|
||||
|
||||
describe("AddLaneModal — create/update readme", () => {
|
||||
it("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
|
||||
vi.mocked(api.run.start)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
it("no longer offers it here — it moved to the lane's Tools menu", async () => {
|
||||
// Creating a lane and writing its docs are separate decisions, and the
|
||||
// second one is worth re-running later; see LaneToolsMenu.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — AI Router", () => {
|
||||
it("applies native without asking, so a lane always has a declared endpoint", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
@@ -415,67 +601,121 @@ describe("AddLaneModal — create/update readme", () => {
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByLabelText("Create/update readme file"));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.run.start).toHaveBeenCalledWith({
|
||||
laneId: 9,
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
|
||||
});
|
||||
|
||||
it("is hidden for a codex lane, which has no slash commands", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Codex" }));
|
||||
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||
// any screen.
|
||||
it("sends the picked profile once AI Router is chosen", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle" }),
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||
).toBeInTheDocument();
|
||||
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.selectOptions(select, "ship-feature");
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pipeline: "ship-feature" })
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
|
||||
});
|
||||
|
||||
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||
it("keeps the radio on Native when no profile is saved, and says why", async () => {
|
||||
// Choosing "AI Router" with nothing to point at would create a lane whose
|
||||
// declared endpoint does not exist.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
|
||||
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates the lane anyway when the profile list cannot be fetched", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
|
||||
it("reports the adopted lane even when applying the router fails", async () => {
|
||||
// `ensure` already created it server-side; skipping onAdded would hide a
|
||||
// real lane from the list until some unrelated refetch found it.
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
// …and the failure is still surfaced, on a modal left open.
|
||||
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
|
||||
// Deleted in another tab while this modal sat open: the select would show
|
||||
// no matching option while state still held the dead name.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const { rerender } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
|
||||
|
||||
// Re-open with the profile gone from the server's answer.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
|
||||
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
|
||||
});
|
||||
|
||||
it("does not offer AI Router until the profile list has arrived", async () => {
|
||||
// Clicking it early resolved back to Native with no feedback, which reads
|
||||
// as a dead control.
|
||||
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
|
||||
vi.mocked(api.aiRouters.list).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveList = resolve;
|
||||
}) as never
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() => expect(select).toHaveValue("default"));
|
||||
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||
resolveList({ items: [nativeRouter(), savedRouter()] });
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -523,3 +763,41 @@ describe("AddLaneModal — tool selection", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["development", "main"],
|
||||
current: "development",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("jumps to Repo mode from the branch choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByTestId("add-lane-branch-section");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Current branch" }));
|
||||
|
||||
// Repo mode adopts the directory as it stands — no branch section left to
|
||||
// answer, because there is no fork to configure.
|
||||
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("prefills the repo and branch it was opened with", async () => {
|
||||
renderModal({
|
||||
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
|
||||
});
|
||||
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
|
||||
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
// "adopted" is shown as context, not as a refusal.
|
||||
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
|
||||
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -191,7 +191,7 @@ describe("DestructiveLaneModal", () => {
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => {
|
||||
it("ENABLES remove when the worktree directory is missing — the row goes, git's records stay", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
@@ -212,7 +212,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -238,7 +238,7 @@ describe("DestructiveLaneModal", () => {
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => {
|
||||
it("ENABLES remove when the worktree directory is unreadable — nothing on disk is touched either way", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -268,23 +268,34 @@ describe("DestructiveLaneModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => {
|
||||
it("never offers Force for a managed removal with unpushed commits — it deletes the row, not the branch", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] })
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
head: "abc0000",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 3,
|
||||
blocked: ["unpushed-commits"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => {
|
||||
@@ -353,10 +364,10 @@ describe("DestructiveLaneModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => {
|
||||
it("confirming an unpushed-commits RESET with Force ticked sends force:true plus the same expect block", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
action: "reset",
|
||||
head: "abc0000",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
@@ -368,13 +379,13 @@ describe("DestructiveLaneModal", () => {
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu } from "../LaneCard";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
@@ -23,14 +23,21 @@ vi.mock("../../../lib/api", () => ({
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
integration: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
syncBaseCheck: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
run: {
|
||||
kill: vi.fn(),
|
||||
start: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,8 +48,10 @@ beforeEach(() => {
|
||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.integration).mockReset();
|
||||
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
|
||||
// Only the built-in profile by default: a user who never opened the AI Router
|
||||
// tab has nothing to switch between, so the row stays out of every other test.
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
||||
});
|
||||
vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
@@ -150,19 +159,17 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the progress percentage", () => {
|
||||
// The pipeline map below this card draws the same progress as named nodes,
|
||||
// so the card no longer repeats it as a bar and a percent.
|
||||
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
|
||||
expect(screen.queryByText("48%")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
it("leaves the needs-you message to the strip card", () => {
|
||||
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
||||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/waiting on approval/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
|
||||
@@ -197,64 +204,11 @@ describe("LaneCard rebuilt layout", () => {
|
||||
});
|
||||
|
||||
describe("LaneCard git block", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(block.textContent).toContain("2");
|
||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
|
||||
it("carries no working-copy row — that heads the Changes panel now", async () => {
|
||||
render(<LaneCard lane={makeLane({ id: 4 })} onAction={vi.fn()} />);
|
||||
expect(await screen.findByTestId("lane-card-4")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the card unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(api.lanes.git).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,40 +294,6 @@ describe("LaneCard — named locks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard — integration badges", () => {
|
||||
beforeEach(() => {
|
||||
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||
// keeps the double from having to restate a shape the component never
|
||||
// touches here.
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
available: true as const,
|
||||
provisioned: true as const,
|
||||
up: false,
|
||||
slot: 1,
|
||||
profileDir: "/work/demo/.agenthub/profile",
|
||||
ports: {},
|
||||
} as unknown as LaneRuntime);
|
||||
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
||||
Promise.resolve({ enabled: name === "tracker" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows integration badges reflecting each toggle's state", async () => {
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const tracker = await screen.findByTestId("lane-integration-tracker");
|
||||
const devQc = await screen.findByTestId("lane-integration-dev_qc");
|
||||
expect(tracker.className).toContain("status-success");
|
||||
expect(devQc.className).not.toContain("status-success");
|
||||
});
|
||||
|
||||
it("hides integration badges when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneToolsMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
@@ -433,6 +353,29 @@ describe("LaneToolsMenu", () => {
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
||||
});
|
||||
|
||||
it("clicking Create/update readme opens the lane's terminal on the docs skill", async () => {
|
||||
// Moved here from the Add-lane modal: a skill only runs inside a CLI
|
||||
// session, and writing the docs is worth re-running long after creation.
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneToolsMenu lane={makeLane({ cwd: "/work/demo" })} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-write-docs"));
|
||||
await waitFor(() =>
|
||||
expect(api.run.start).toHaveBeenCalledWith({
|
||||
laneId: 1,
|
||||
cwd: "/work/demo",
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("hides Create/update readme on a codex lane, which has no slash commands", async () => {
|
||||
render(<LaneToolsMenu lane={makeLane({ tool: "codex" })} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
|
||||
@@ -440,3 +383,168 @@ describe("LaneToolsMenu", () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneRouterRow — the Model picker", () => {
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
|
||||
|
||||
it("stays out of the card when native is the only profile", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-router-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the lane's current profile selected", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies a switch through the router route, not a lane PATCH", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a restart when a live pane still holds the old router's models", async () => {
|
||||
// Claude Code re-applies `env` to a running session but keeps the model it
|
||||
// resolved at startup, so the switch is only half-applied until the pane is
|
||||
// restarted — the server says so and the row must act on it.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: true,
|
||||
run_id: "agenthub-lane-1",
|
||||
});
|
||||
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
|
||||
const restart = await screen.findByRole("button", { name: "Restart" });
|
||||
await userEvent.click(restart);
|
||||
|
||||
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
|
||||
// Started by lane id alone: replaying the old run's --model would name a
|
||||
// model of the router the user just left.
|
||||
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
|
||||
});
|
||||
|
||||
it("leaves the restart button out when no pane is running", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
|
||||
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
|
||||
// lane-detail header), so its selected value is component state that
|
||||
// outlives a lane switch unless the sync effect puts the new lane's own
|
||||
// profile back.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/b/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
const { rerender } = render(<LaneRouterRow lane={laneA} />);
|
||||
await screen.findByDisplayValue("Native (Claude / Codex)");
|
||||
|
||||
rerender(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
|
||||
// The row is one instance shared by every lane, so an apply still in flight
|
||||
// when the user moves on would paint its rollback and its error on whoever
|
||||
// is on screen by then — lane B's failure changing lane A's picker.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
let reject: (e: Error) => void = () => {};
|
||||
vi.mocked(api.lanes.routerApply).mockImplementation(
|
||||
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
|
||||
);
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
|
||||
const { rerender } = render(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
await act(async () => {
|
||||
reject(new Error("EBADJSON"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("EBADJSON")).toBeNull();
|
||||
// And the picker must not be left locked by a request for another lane.
|
||||
expect(screen.getByRole("combobox")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("puts the selection back when the apply fails", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* @file LaneDiffView.test.tsx
|
||||
* @description Pins the side-by-side renderer: two independently scrolling
|
||||
* columns (a long line must be reachable, not painted over its neighbour)
|
||||
* whose horizontal scroll is mirrored, so the two halves of a line stay
|
||||
* opposite each other. Also pins the clamped mirroring (a narrow column may
|
||||
* not drag the wide one back) and the row windowing of large diffs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
ROW_PX,
|
||||
WINDOW_THRESHOLD,
|
||||
analyseHunk,
|
||||
visibleRange,
|
||||
} from "../LaneDiffView";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,2 +1,2 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** jsdom has no layout: give a scroller the extents a browser would. */
|
||||
function layout(
|
||||
el: HTMLElement,
|
||||
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
|
||||
) {
|
||||
for (const [k, v] of Object.entries(g))
|
||||
Object.defineProperty(el, k, { value: v, configurable: true });
|
||||
}
|
||||
|
||||
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
|
||||
|
||||
describe("LaneDiffView — side by side", () => {
|
||||
it("gives each column its own scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
// windowed one keeps the cap because windowing needs its own scroller.
|
||||
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
|
||||
const big: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
|
||||
type: "ctx" as const,
|
||||
oldLine: i + 1,
|
||||
newLine: i + 1,
|
||||
text: `line ${i}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(<LaneDiffView file={big} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
||||
|
||||
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
|
||||
it("mirrors one column's horizontal scroll onto the other", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollLeft = 320;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(320);
|
||||
|
||||
right.scrollLeft = 0;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the split columns on the same row vertically too", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollTop = 480;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollTop).toBe(480);
|
||||
|
||||
right.scrollTop = 40;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps the mirrored offset to the other column's own extent", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
right.scrollTop = 250;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(300);
|
||||
expect(left.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("does not let the narrow column's clamped echo drag the wide one back", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
fireEvent.scroll(right);
|
||||
// A browser fires the narrow column's scroll for the clamped write.
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(600);
|
||||
|
||||
// A real move of the narrow column still carries the wide one.
|
||||
left.scrollLeft = 100;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(100);
|
||||
});
|
||||
|
||||
it("keeps the unified layout on one scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
|
||||
});
|
||||
});
|
||||
|
||||
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
|
||||
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
|
||||
}
|
||||
|
||||
const ROWS = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
|
||||
]);
|
||||
|
||||
/** The unified row whose text contains `needle`. */
|
||||
function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
|
||||
r.textContent?.includes(needle)
|
||||
);
|
||||
if (!row) throw new Error(`no row for ${needle}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
expect(plus.className).toContain("text-status-success");
|
||||
expect(minus.textContent).toBe("-");
|
||||
expect(minus.className).toContain("text-status-danger");
|
||||
expect(ctx.className).toContain("text-fg-muted");
|
||||
expect(ctx.className).not.toContain("font-bold");
|
||||
});
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("does not emphasise a line that changed completely", () => {
|
||||
const { container } = render(
|
||||
<LaneDiffView
|
||||
file={fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
|
||||
])}
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
render(
|
||||
<LaneDiffView
|
||||
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
|
||||
layout="split"
|
||||
/>
|
||||
);
|
||||
const rows = (id: string) => [
|
||||
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
const [right] = rows("lane-diff-split-right");
|
||||
const [left] = rows("lane-diff-split-left");
|
||||
expect(right!.className).toContain("bg-status-success/20");
|
||||
expect(left!.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyseHunk", () => {
|
||||
const hunk = {
|
||||
header: "@@ -1,4 +1,4 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
|
||||
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
|
||||
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
|
||||
],
|
||||
};
|
||||
|
||||
it("pairs each del/add with its emphasis, keyed by line index", () => {
|
||||
const a = analyseHunk(hunk);
|
||||
expect(a.rows).toHaveLength(3);
|
||||
expect(a.emph.get(1)).toEqual([6, 7]);
|
||||
expect(a.emph.get(2)).toEqual([6, 7]);
|
||||
expect(a.emph.get(3)).toEqual([6, 7]);
|
||||
expect(a.emph.get(4)).toEqual([6, 7]);
|
||||
expect(a.emph.has(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("computes once per hunk, so both split columns and re-renders share it", () => {
|
||||
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleRange", () => {
|
||||
it("covers the viewport plus overscan on both sides", () => {
|
||||
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
|
||||
});
|
||||
|
||||
it("clamps a scrollTop past the content to the last full viewport", () => {
|
||||
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
|
||||
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
});
|
||||
|
||||
it("never runs past a short list", () => {
|
||||
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
|
||||
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
function bigFile(n: number): LaneChangeFile {
|
||||
const lines = Array.from({ length: n }, (_, i) =>
|
||||
i % 2
|
||||
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
|
||||
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
|
||||
);
|
||||
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
|
||||
return fileWith(lines);
|
||||
}
|
||||
|
||||
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
|
||||
|
||||
describe("LaneDiffView — windowing", () => {
|
||||
it("renders every row below the threshold, with no spacers", () => {
|
||||
const { container } = render(
|
||||
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
|
||||
);
|
||||
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
|
||||
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const rows = rowsIn(container);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThan(300);
|
||||
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
|
||||
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
|
||||
// header + 5000 lines, every item one fixed row tall
|
||||
const rendered = container.querySelectorAll("[data-diff-item]").length;
|
||||
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
|
||||
});
|
||||
|
||||
it("moves the window with the scroll", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
expect(container.textContent).not.toContain("line 3000;");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("line 3000;");
|
||||
expect(container.textContent).not.toContain("line 10;");
|
||||
});
|
||||
|
||||
it("starts windowing where the reader is when a diff grows past the threshold", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 350 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
// Expanding context replaces the file in place, same mounted view.
|
||||
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 350;");
|
||||
});
|
||||
|
||||
it("starts from the top again after a round trip through split", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
// In split the outer box is no scroller; the browser drops its offset.
|
||||
screen.getByTestId("lane-diff-view").scrollTop = 0;
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 5;");
|
||||
});
|
||||
|
||||
it("holds the horizontal extent at the longest line, rendered or not", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
|
||||
// the 300-w line 7 is off screen now, and still sets the width
|
||||
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
|
||||
});
|
||||
|
||||
it("windows both split columns over the same row range", () => {
|
||||
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
right.scrollTop = 2000 * ROW_PX;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(2000 * ROW_PX);
|
||||
const l = rowsIn(left);
|
||||
const r = rowsIn(right);
|
||||
expect(l.length).toBeLessThan(300);
|
||||
expect(l).toHaveLength(r.length);
|
||||
expect(right.textContent).toContain("line 2001;");
|
||||
// same rows: the right column's add has its filler row on the left
|
||||
expect(left.textContent).toContain("line 2000;");
|
||||
});
|
||||
|
||||
it("windows a whole-file read too", () => {
|
||||
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
|
||||
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
|
||||
expect(rowsIn(container).length).toBeLessThan(300);
|
||||
const view = screen.getByTestId("lane-file-view");
|
||||
view.scrollTop = 4000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("row 4000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
const hunk = (n: number) => ({
|
||||
header: `@@ -${n},1 +${n},1 @@`,
|
||||
context: "",
|
||||
oldStart: n,
|
||||
newStart: n,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
|
||||
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
|
||||
],
|
||||
});
|
||||
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
|
||||
|
||||
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
|
||||
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
|
||||
function renderInScroller() {
|
||||
const view = render(
|
||||
<div data-testid="scroller" style={{ overflowY: "auto" }}>
|
||||
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
|
||||
</div>
|
||||
);
|
||||
const scroller = screen.getByTestId("scroller");
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 100 });
|
||||
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
|
||||
const k = Number(el.dataset.hunkIndex);
|
||||
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
|
||||
}
|
||||
return scroller;
|
||||
}
|
||||
|
||||
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
|
||||
const scroller = renderInScroller();
|
||||
const next = screen.getAllByRole("button", { name: "Next change" });
|
||||
const prev = screen.getAllByRole("button", { name: "Previous change" });
|
||||
|
||||
fireEvent.click(next[0]!); // bar 1 → hunk 2
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
fireEvent.click(next[1]!); // bar 2 → hunk 3
|
||||
expect(scroller.scrollTop).toBe(200);
|
||||
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("counts the hunks and disables the arrows at either end", () => {
|
||||
renderInScroller();
|
||||
const bars = screen.getAllByTestId("lane-hunk-bar");
|
||||
expect(bars.map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 3"),
|
||||
expect.stringContaining("2 of 3"),
|
||||
expect.stringContaining("3 of 3"),
|
||||
]);
|
||||
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
|
||||
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
|
||||
});
|
||||
|
||||
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
|
||||
render(<LaneDiffView file={three} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||
return {
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [],
|
||||
hasRemote: true,
|
||||
upstream: "origin/feat/thing",
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
||||
action: "fetch",
|
||||
ok: true,
|
||||
output: "",
|
||||
});
|
||||
vi.mocked(api.lanes.gitBranches)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneGitBar", () => {
|
||||
it("shows both sides of the upstream comparison", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
const tracking = screen.getByTestId("lane-git-tracking");
|
||||
expect(tracking).toHaveTextContent("2");
|
||||
expect(tracking).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("disables the network actions when the repo has no remote", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
||||
// Local actions stay available — a repo with no remote still branches.
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
// Picking is harmless; applying is what a dirty tree blocks.
|
||||
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches branch on a clean tree", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.selectOptions(select, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={onNewLane}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "feat/sibling",
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "New lane here" }));
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
|
||||
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing", "feat/free"],
|
||||
current: "feat/thing",
|
||||
worktrees: [
|
||||
{ path: "/repo/lane3", branch: "feat/thing" },
|
||||
{ path: "/repo/dev", branch: "development" },
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
const newLane = () => screen.getByRole("button", { name: "New lane here" });
|
||||
|
||||
// This lane's own branch: its worktree is this lane.
|
||||
await user.selectOptions(select, "feat/thing");
|
||||
expect(newLane()).toBeDisabled();
|
||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
||||
await user.selectOptions(select, "development");
|
||||
expect(newLane()).toBeDisabled();
|
||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
|
||||
await user.selectOptions(select, "feat/free");
|
||||
expect(newLane()).toBeEnabled();
|
||||
});
|
||||
|
||||
it("prints git's own rejection text when an action fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
|
||||
);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
|
||||
});
|
||||
|
||||
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("sends one branch-create for a double Enter", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
|
||||
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
|
||||
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolveOld = r;
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
// Lane 3's answer lands late: its worktree held `development`.
|
||||
resolveOld({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||
});
|
||||
await user.selectOptions(select, "development");
|
||||
const newLane = screen.getByRole("button", { name: "New lane here" });
|
||||
expect(newLane).toBeEnabled();
|
||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
});
|
||||
|
||||
it("claims no sync state when the upstream comparison could not be counted", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("in sync")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains a push with nowhere to go instead of the server's generic text", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
|
||||
);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
|
||||
"No remote to push to or pull from"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @file LaneGitHeader.test.tsx
|
||||
* @description Pins the Changes panel's working-copy line: the live branch,
|
||||
* head, subject and uncommitted counts, the per-file hover panel behind the
|
||||
* counts, that a lane switch drops the previous lane's facts at once, and that
|
||||
* an unreadable repo renders nothing rather than an error.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
});
|
||||
|
||||
/** The panel's own wiring: one facts read feeding the header. */
|
||||
function Harness({ laneId }: { laneId: number }) {
|
||||
const [git, refresh] = useLaneGitFacts(laneId);
|
||||
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
|
||||
}
|
||||
|
||||
describe("LaneGitHeader", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
const { rerender } = render(<Harness laneId={1} />);
|
||||
await screen.findByText(/feat\/rename-metric/);
|
||||
|
||||
// The next lane's read never lands: nothing of lane 1's git may linger.
|
||||
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
|
||||
rerender(<Harness laneId={2} />);
|
||||
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<Harness laneId={1} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders nothing and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<Harness laneId={2} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
|
||||
expect(screen.queryByTestId("lane-git-header")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<Harness laneId={3} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the panel unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<Harness laneId={1} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @file Rendering tests for the Workspace lane tile, focused on the amber
|
||||
* "detected" badge: it may only appear when the inferred stage is genuinely
|
||||
* AHEAD of the declared one, including when the declaration used a node alias
|
||||
* (which is most of what the ship-feature skill declares).
|
||||
* @file Rendering tests for the Workspace lane tile: the amber "detected"
|
||||
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
|
||||
* declared one, including when the declaration used a node alias — which is
|
||||
* most of what the ship-feature skill declares), and the reorder handles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import LaneStripCard from "../LaneStripCard";
|
||||
import type { Lane, LaneNode } from "../../../lib/types";
|
||||
|
||||
@@ -63,8 +63,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
@@ -77,8 +79,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "implementing" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
|
||||
@@ -88,10 +92,104 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneStripCard reorder", () => {
|
||||
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
|
||||
function dataTransfer(initial = "") {
|
||||
let payload = initial;
|
||||
return {
|
||||
setData: (_type: string, v: string) => {
|
||||
payload = v;
|
||||
},
|
||||
getData: () => payload,
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
};
|
||||
}
|
||||
|
||||
it("reports the dragged tile's index and the drop target's", () => {
|
||||
const onReorder = vi.fn();
|
||||
const dt = dataTransfer();
|
||||
render(
|
||||
<>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 1 })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 2 })}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
|
||||
expect(onReorder).toHaveBeenCalledWith(0, 2);
|
||||
});
|
||||
|
||||
it("ignores a drop carrying no tile index", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-1");
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 1);
|
||||
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 3);
|
||||
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft" });
|
||||
expect(onReorder).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the id without the 'Lane' prefix", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 7, title: "checkout flow" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-7");
|
||||
expect(tile).toHaveTextContent("#7");
|
||||
expect(tile.textContent).not.toMatch(/Lane/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,20 @@
|
||||
* swaps the whole pane over to the new lane — its cwd, its tool, its history,
|
||||
* and its live tmux session — instead of leaving the previous lane's terminal
|
||||
* on screen under a new lane's header.
|
||||
*
|
||||
* Each pane is one tmux session on its lane — split panes all show the same
|
||||
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
|
||||
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
|
||||
* that and attach to the still-live session instead of failing outright
|
||||
* (with a notice explaining what happened), since that session is still
|
||||
* reachable and the user did not ask to kill it. `killRun` is the other half:
|
||||
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
|
||||
* DOES want that previous session gone so a fresh start/resume can proceed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, AlertCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -38,6 +48,20 @@ import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
|
||||
/** The claude lane a session's cwd belongs to: the longest path-boundary
|
||||
* prefix, the same rule the server binds hook sessions with. External
|
||||
* sessions are all claude — Codex emits no hooks. */
|
||||
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
|
||||
let best: Lane | null = null;
|
||||
for (const l of lanes) {
|
||||
if (l.tool !== "claude") continue;
|
||||
const root = l.cwd.replace(/\/+$/, "");
|
||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
laneId: number | null;
|
||||
@@ -53,6 +77,19 @@ export interface LaneConsolePaneProps {
|
||||
wsConnected: boolean;
|
||||
defaultCwd?: string;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
/** Which split pane this is. Every pane shows the SAME lane, each in its own
|
||||
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
|
||||
slot?: number;
|
||||
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
|
||||
* one terminal is never attached twice side by side. */
|
||||
hiddenRunIds?: string[];
|
||||
/** Reports the run this pane shows (null on the setup form). */
|
||||
onHandleChange?: (runId: string | null) => void;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
|
||||
function paneRunId(laneId: number, slot: number): string {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
export function LaneConsolePane({
|
||||
@@ -68,6 +105,9 @@ export function LaneConsolePane({
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
slot = 0,
|
||||
hiddenRunIds,
|
||||
onHandleChange,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
@@ -75,7 +115,7 @@ export function LaneConsolePane({
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("auto");
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
|
||||
const [resumeSession, setResumeSession] = useState<Session | null>(null);
|
||||
@@ -88,12 +128,37 @@ export function LaneConsolePane({
|
||||
|
||||
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||
const laneTool = currentLane?.tool ?? "claude";
|
||||
// The page polls runs and sessions for EVERY lane; this pane's Active runs
|
||||
// list is this lane's only. No lane selected keeps the full lists.
|
||||
const laneRuns = useMemo(
|
||||
() =>
|
||||
laneId === null || !activeRuns
|
||||
? activeRuns
|
||||
: {
|
||||
...activeRuns,
|
||||
items: activeRuns.items.filter(
|
||||
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
|
||||
),
|
||||
},
|
||||
[activeRuns, laneId, hiddenRunIds]
|
||||
);
|
||||
const laneSessions = useMemo(
|
||||
() =>
|
||||
laneId === null
|
||||
? externalSessions
|
||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
}, [handle, onHasActiveRunChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onHandleChange?.(handle?.id ?? null);
|
||||
}, [handle, onHandleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLane && defaultCwd && cwd === "") {
|
||||
setCwd(defaultCwd);
|
||||
@@ -129,43 +194,19 @@ export function LaneConsolePane({
|
||||
useEffect(() => {
|
||||
const { activeRuns: runs } = latest.current;
|
||||
setPrompt("");
|
||||
lastPrefill.current = "";
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
||||
const own = laneId === null ? null : paneRunId(laneId, slot);
|
||||
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
|
||||
autoAttachedForLane.current = null;
|
||||
refreshList();
|
||||
}, [laneId, refreshList]);
|
||||
}, [laneId, slot, refreshList]);
|
||||
|
||||
// Pre-fill the prompt with the slash command the lane's pipeline template
|
||||
// names as its driver, so picking the 16-node pipeline starts the 16-step
|
||||
// skill and the 8-node one the 8-step skill. Only overwrites a prompt the
|
||||
// user hasn't touched (still exactly the previous prefill), so switching
|
||||
// templates never eats a typed requirement. Must be declared AFTER the
|
||||
// lane-switch effect: on a lane change that one clears both first.
|
||||
const lastPrefill = useRef("");
|
||||
// Claude gets a slash command it can execute (`pipeline_skill`), which itself
|
||||
// declares stage at every step. Codex has no slash commands — the same
|
||||
// string would read as literal prose — so it gets the equivalent priming a
|
||||
// different way: a plain-text checklist of this lane's own pipeline node ids,
|
||||
// read directly off `pipeline_nodes` so it always matches the template in
|
||||
// use. Without SOME opening nudge a codex lane's declared stage depends
|
||||
// entirely on AGENTS.md being read on its own, which is easy to miss.
|
||||
const promptPrefill =
|
||||
laneTool === "codex"
|
||||
? currentLane?.pipeline_nodes?.length
|
||||
? `Declare progress as you go: run \`agenthub stage <stage> --tool codex --evidence "..."\` immediately at each real transition through this pipeline — ${currentLane.pipeline_nodes.map((n) => n.id).join(" -> ")}. Then, your task:\n\n`
|
||||
: ""
|
||||
: currentLane?.pipeline_skill || "";
|
||||
useEffect(() => {
|
||||
const next = promptPrefill && laneTool === "claude" ? `${promptPrefill} ` : promptPrefill;
|
||||
setPrompt((p) => (p === lastPrefill.current ? next : p));
|
||||
lastPrefill.current = next;
|
||||
// `laneId` is a dep too: switching between two lanes on the SAME template
|
||||
// leaves `promptPrefill` unchanged, and the switch effect just cleared the
|
||||
// prompt — without it the new lane would open with an empty box.
|
||||
}, [promptPrefill, laneTool, laneId]);
|
||||
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
|
||||
// slash command (or the codex stage-checklist prose) is sent as soon as the
|
||||
// run starts, which is an extra prompt nobody asked for. The prompt is the
|
||||
// user's to type; the pipeline is declared by `agenthub stage` instead.
|
||||
|
||||
// The switch effect above only sees whatever `activeRuns` the page already
|
||||
// had loaded at that instant. Remounting this page (navigating away and
|
||||
@@ -177,12 +218,13 @@ export function LaneConsolePane({
|
||||
useEffect(() => {
|
||||
if (handle || laneId === null) return;
|
||||
if (autoAttachedForLane.current === laneId) return;
|
||||
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
|
||||
const own = paneRunId(laneId, slot);
|
||||
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
|
||||
if (running) {
|
||||
autoAttachedForLane.current = laneId;
|
||||
setHandle(running);
|
||||
}
|
||||
}, [activeRuns, laneId, handle]);
|
||||
}, [activeRuns, laneId, slot, handle]);
|
||||
|
||||
// The cwd tracks the lane's own folder separately, keyed on the resolved
|
||||
// path rather than on `laneId` alone: the pane can mount before the lane
|
||||
@@ -261,6 +303,7 @@ export function LaneConsolePane({
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: args.initialPrompt || "",
|
||||
model: args.model || undefined,
|
||||
permissionMode: args.permissionMode,
|
||||
@@ -271,29 +314,31 @@ export function LaneConsolePane({
|
||||
resumeLast: args.resumeLast,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
||||
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
await attachToRun(updatedLane.run_id);
|
||||
return;
|
||||
}
|
||||
// A code-only check: the server's message is human prose ("lane
|
||||
// already has a live run"), never the literal strings "409" or
|
||||
// "ERUNLIVE" — matching against `.message` here is dead code that
|
||||
// always falls through to the raw error below.
|
||||
// This pane's own session is the live one — land on it.
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error(t("errors.noRunIdReturned"));
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
const fetched = await api.run.get(startedId);
|
||||
setHandle(fetched);
|
||||
refreshList();
|
||||
} catch {
|
||||
try {
|
||||
await attachToRun(laneStartResult.lane.run_id);
|
||||
await attachToRun(startedId);
|
||||
refreshList();
|
||||
} catch (fallbackErr: unknown) {
|
||||
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
|
||||
@@ -307,7 +352,18 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
|
||||
[
|
||||
busy,
|
||||
t,
|
||||
lanes,
|
||||
laneId,
|
||||
laneTool,
|
||||
slot,
|
||||
onLaneIdChange,
|
||||
onLaneCreated,
|
||||
attachToRun,
|
||||
refreshList,
|
||||
]
|
||||
);
|
||||
|
||||
const onResumeFromHistory = useCallback(
|
||||
@@ -332,19 +388,38 @@ export function LaneConsolePane({
|
||||
onLaneCreated(ensureResult.lane);
|
||||
}
|
||||
|
||||
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
// The lane's own pane is still busy with a PREVIOUS live session —
|
||||
// that session is still active and reachable, so land on it
|
||||
// instead of failing outright. Killing it (Active runs → Kill) is
|
||||
// what actually frees the lane for this resume. Checked by `.code`
|
||||
// — the server's `.message` is prose, never the literal string
|
||||
// "ERUNLIVE" or "409".
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
onLaneIdChange(targetLaneId);
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error("No run_id returned from lane start");
|
||||
}
|
||||
|
||||
fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
fetched = await api.run.get(startedId);
|
||||
onLaneIdChange(targetLaneId);
|
||||
} else {
|
||||
fetched = await api.run.start({
|
||||
@@ -368,7 +443,7 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
|
||||
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
@@ -385,6 +460,28 @@ export function LaneConsolePane({
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Frees a lane blocked by a live run nobody wants anymore (see Active
|
||||
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
|
||||
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
|
||||
const killRun = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy("kill");
|
||||
setError(null);
|
||||
try {
|
||||
await api.run.kill(id);
|
||||
if (handle?.id === id) newRun();
|
||||
refreshList();
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[handle, newRun, refreshList, t]
|
||||
);
|
||||
|
||||
if (laneId === null && showLaneSelector) {
|
||||
return (
|
||||
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
@@ -426,13 +523,17 @@ export function LaneConsolePane({
|
||||
</select>
|
||||
)}
|
||||
|
||||
<header className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-4.5 h-4.5 text-accent" />
|
||||
{/* Title, live badge and blurb on ONE line: the blurb is a standing
|
||||
description of the panel, not a message, so it does not earn the
|
||||
second row it used to wrap onto. It drops out entirely below `sm`,
|
||||
where the width would wrap it again. */}
|
||||
<header className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-3.5 h-3.5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<h1 className="text-base font-semibold text-fg-primary">
|
||||
{t("title", { tool: toolLabel })}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
@@ -447,16 +548,19 @@ export function LaneConsolePane({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle", { tool: toolLabel })}</p>
|
||||
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
|
||||
{t("subtitle", { tool: toolLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
activeRuns={laneRuns}
|
||||
currentHandleId={handle?.id || null}
|
||||
onAttach={attachToRun}
|
||||
runHistory={runHistory}
|
||||
externalSessions={externalSessions}
|
||||
externalSessions={laneSessions}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onKill={killRun}
|
||||
onRefresh={refreshList}
|
||||
/>
|
||||
</header>
|
||||
@@ -510,6 +614,13 @@ export function LaneConsolePane({
|
||||
<TerminalView
|
||||
runId={handle!.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// go straight back to the setup form rather than leaving a dead
|
||||
// terminal the user has to click "New run" out of.
|
||||
onExit={() => {
|
||||
newRun();
|
||||
refreshList();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={newRun}
|
||||
|
||||
@@ -10,18 +10,23 @@
|
||||
* - `RunsModal` — the merged list itself: live in-memory handles from
|
||||
* `activeRuns` deduped against persistent `runHistory`, newest first, with
|
||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||
* Resume / View actions.
|
||||
* Resume / View / Kill actions.
|
||||
*
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd.
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||
* from them for the same reason.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns`,
|
||||
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||
* just calls `onRefresh`.
|
||||
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
|
||||
* runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
|
||||
* discarding a file in the Changes panel) is what actually frees a lane whose
|
||||
* pane is still busy with a previous session — `LaneConsolePane` needs it
|
||||
* because starting or resuming into a lane already holding a live run answers
|
||||
* 409 ERUNLIVE rather than overwriting a prompt in flight.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -39,6 +44,7 @@ import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
@@ -52,7 +58,7 @@ function StatusPill({
|
||||
const colors: Record<string, string> = {
|
||||
running: "bg-status-success/10 text-status-success border-status-success/30",
|
||||
gone: "bg-surface-3 text-fg-secondary border-border",
|
||||
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
|
||||
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
|
||||
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
|
||||
killed: "bg-surface-3 text-fg-secondary border-border",
|
||||
abandoned: "bg-surface-3 text-fg-secondary border-border",
|
||||
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions = [],
|
||||
onResumeFromHistory,
|
||||
onViewFromHistory,
|
||||
onKill,
|
||||
onRefresh,
|
||||
}: {
|
||||
activeRuns: RunListResponse | null;
|
||||
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions?: Session[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
|
||||
* spawned rows only — an external session has no tmux session here to
|
||||
* kill. */
|
||||
onKill: (id: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
|
||||
setOpen(false);
|
||||
onViewFromHistory(item);
|
||||
}}
|
||||
onKill={onKill}
|
||||
runHistory={historyItems}
|
||||
onClose={() => setOpen(false)}
|
||||
onRefresh={onRefresh}
|
||||
@@ -273,6 +285,7 @@ export function RunsModal({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
runHistory,
|
||||
onClose,
|
||||
onRefresh,
|
||||
@@ -282,6 +295,7 @@ export function RunsModal({
|
||||
onAttach: (id: string) => void;
|
||||
onResume: (item: DashboardRunHistoryItem) => void;
|
||||
onView: (item: DashboardRunHistoryItem) => void;
|
||||
onKill: (id: string) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
@@ -446,6 +460,7 @@ export function RunsModal({
|
||||
const h = historyById.get(r.id);
|
||||
if (h) onView(h);
|
||||
}}
|
||||
onKill={() => onKill(r.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -512,14 +527,20 @@ function UnifiedRunRowView({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
}: {
|
||||
row: UnifiedRunRow;
|
||||
isCurrent: boolean;
|
||||
onAttach: () => void;
|
||||
onResume: () => void;
|
||||
onView: () => void;
|
||||
onKill: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
// Two clicks before the tmux session actually dies — same convention as
|
||||
// discarding a file's changes in the Changes panel, for the one action
|
||||
// here that cannot be undone.
|
||||
const [confirmKill, setConfirmKill] = useState(false);
|
||||
const startedDate = new Date(row.startedAt);
|
||||
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
|
||||
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
|
||||
@@ -553,7 +574,7 @@ function UnifiedRunRowView({
|
||||
)}
|
||||
{row.external && (
|
||||
<span
|
||||
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("runs.externalHint")}
|
||||
>
|
||||
{t("runs.externalBadge")}
|
||||
@@ -593,6 +614,32 @@ function UnifiedRunRowView({
|
||||
{t("runs.viewLabel", "View")}
|
||||
</button>
|
||||
)}
|
||||
{row.isLive && !row.external && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirmKill) {
|
||||
setConfirmKill(true);
|
||||
return;
|
||||
}
|
||||
setConfirmKill(false);
|
||||
onKill();
|
||||
}}
|
||||
onBlur={() => setConfirmKill(false)}
|
||||
title={
|
||||
confirmKill
|
||||
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
|
||||
: t("runs.killLabel", "Kill")
|
||||
}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
|
||||
confirmKill
|
||||
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
|
||||
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
|
||||
}`}
|
||||
>
|
||||
<Square className="w-3 h-3" />
|
||||
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{row.promptPreview && (
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ShieldAlert,
|
||||
X,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Home,
|
||||
History as HistoryIcon,
|
||||
Search,
|
||||
@@ -52,45 +53,305 @@ import type {
|
||||
RunStartArgs,
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
CcMdItem,
|
||||
} from "../../lib/api";
|
||||
import type { Session, LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
|
||||
// Minimal SlashCommand type (from deleted RunConsole)
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
source: "project" | "user" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Minimal PromptEditor component (from deleted RunConsole)
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
"compact",
|
||||
"config",
|
||||
"model",
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
"status",
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"output-style",
|
||||
"exit",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
|
||||
* description (3+ chars only, so one keystroke drags nothing tangential in). */
|
||||
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
|
||||
if (!q) return 1;
|
||||
const n = name.toLowerCase();
|
||||
if (n === q) return 1000;
|
||||
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
|
||||
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
|
||||
const idx = n.indexOf(q);
|
||||
if (idx >= 0) return 400 - Math.min(idx, 100);
|
||||
let i = 0;
|
||||
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
|
||||
if (i === q.length) return 200;
|
||||
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
kind: "slash" | "file";
|
||||
query: string;
|
||||
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
|
||||
start: number;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
|
||||
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
|
||||
function detectTrigger(value: string, cursor: number): Trigger | null {
|
||||
let start = cursor;
|
||||
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
|
||||
const tok = value.slice(start, cursor);
|
||||
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
|
||||
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PromptEditorProps {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
slashCommands: SlashCommand[];
|
||||
/** Offer slash commands. Off for codex: these are Claude Code's. */
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
}
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
slash,
|
||||
fileCwd,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
|
||||
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
|
||||
if (commandsCwd !== fileCwd) {
|
||||
setCommandsCwd(fileCwd);
|
||||
setCommands(null);
|
||||
}
|
||||
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
|
||||
|
||||
// Commands and skills are read the first time `/` is typed for this cwd —
|
||||
// two directory walks nobody should pay for on every render of the form.
|
||||
useEffect(() => {
|
||||
if (kind !== "slash" || commands) return;
|
||||
let live = true;
|
||||
const toCmd = (c: CcMdItem): SlashCommand => ({
|
||||
name: c.name,
|
||||
source: c.scope,
|
||||
description: c.frontmatter?.description || c.preview.slice(0, 80),
|
||||
});
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [kind, commands, fileCwd]);
|
||||
|
||||
// Files: debounced, and only the newest query may land.
|
||||
const query = trigger?.query ?? "";
|
||||
useEffect(() => {
|
||||
if (kind !== "file") return;
|
||||
let live = true;
|
||||
const tid = window.setTimeout(() => {
|
||||
api.run
|
||||
.files(fileCwd, query)
|
||||
.then((r) => live && setFiles(r.items))
|
||||
.catch(() => live && setFiles([]));
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(tid);
|
||||
};
|
||||
}, [kind, query, fileCwd]);
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (kind !== "slash" || !commands) return [];
|
||||
const q = query.toLowerCase();
|
||||
return commands
|
||||
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
|
||||
a.cmd.name.localeCompare(b.cmd.name)
|
||||
)
|
||||
.map((x) => x.cmd);
|
||||
}, [kind, commands, query]);
|
||||
const count = kind === "file" ? files.length : slashItems.length;
|
||||
const current = Math.min(active, Math.max(0, count - 1));
|
||||
|
||||
const pick = (token: string) => {
|
||||
if (!trigger) return;
|
||||
const before = value.slice(0, trigger.start);
|
||||
const after = value.slice(trigger.cursor);
|
||||
const sep = after.startsWith(" ") ? "" : " ";
|
||||
onChange(before + token + sep + after);
|
||||
setTrigger(null);
|
||||
setActive(0);
|
||||
const pos = before.length + token.length + 1;
|
||||
requestAnimationFrame(() => {
|
||||
taRef.current?.focus();
|
||||
taRef.current?.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
const pickAt = (i: number) => {
|
||||
if (kind === "file") {
|
||||
const f = files[i];
|
||||
if (f) pick(`@${f}`);
|
||||
} else {
|
||||
const c = slashItems[i];
|
||||
if (c) pick(`/${c.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive(Math.min(count - 1, Math.max(0, current + step)));
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
pickAt(current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setTrigger(null);
|
||||
return;
|
||||
}
|
||||
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
||||
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const retrack = (ta: HTMLTextAreaElement) => {
|
||||
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
|
||||
};
|
||||
|
||||
const rowClass = (i: number) =>
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
{kind && (
|
||||
<div
|
||||
role="listbox"
|
||||
data-testid="prompt-autocomplete"
|
||||
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-fg-muted">
|
||||
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
|
||||
</div>
|
||||
) : kind === "slash" ? (
|
||||
slashItems.map((c, i) => (
|
||||
<button
|
||||
key={`${c.source}:${c.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
// Keep focus in the textarea, or its blur closes the list first.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={rowClass(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
|
||||
>
|
||||
{c.source}
|
||||
</span>
|
||||
</div>
|
||||
{c.description && (
|
||||
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
files.map((f, i) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +380,6 @@ interface RunSetupProps {
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands?: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** The selected lane's CLI. Decides which advanced fields render. */
|
||||
@@ -147,9 +407,84 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Fresh vs resume source picker */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
{/* Run settings and the fresh-vs-resume source picker share one row:
|
||||
settings left, source right. They used to be two rows with a divider
|
||||
between them, which spent a band of height on four labelled selects
|
||||
that are each one line tall. The resume picker below is the only part
|
||||
that still gets a row of its own, and only while resume is chosen. */}
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "auto", label: t("fields.permissionAuto") },
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
@@ -166,8 +501,11 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
</div>
|
||||
|
||||
{resumePicked && (
|
||||
<div className="border-b border-border px-4 py-2 text-[11.5px]">
|
||||
<div className="min-w-0">
|
||||
{props.tool === "claude" ? (
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
@@ -192,8 +530,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
@@ -206,7 +544,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands ?? []}
|
||||
slash={props.tool !== "codex"}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-fg-muted">
|
||||
@@ -214,78 +552,6 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced fields */}
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
@@ -355,7 +621,10 @@ function handleStart(props: RunSetupProps, session?: Session) {
|
||||
effort: props.effort || undefined,
|
||||
resumeSessionId: codex ? undefined : resume?.id || undefined,
|
||||
resumeLast: codex && !resume ? true : undefined,
|
||||
initialPrompt: props.prompt || undefined,
|
||||
// A resume continues a session that already has its own history — sending
|
||||
// whatever is sitting in the prompt box would fire an extra turn nobody
|
||||
// asked for, so a resume carries no prompt at all.
|
||||
initialPrompt: resume ? undefined : props.prompt || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -440,7 +709,10 @@ export function CwdAutocomplete({
|
||||
// Group suggestions by kind preserving fixed order — home first, matching
|
||||
// the neutral default the page pre-fills (issue #202).
|
||||
const groups = useMemo(() => {
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
|
||||
// "repo" (source repos lanes were cut from) sits above the recents: on the
|
||||
// Add-lane form it is the group the field is actually asking for, and on
|
||||
// the Run form it is a short, deliberate list rather than history.
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"];
|
||||
return order
|
||||
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
@@ -516,6 +788,8 @@ export function CwdAutocomplete({
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
) : g.kind === "home" ? (
|
||||
<Home className="w-3 h-3" />
|
||||
) : g.kind === "repo" ? (
|
||||
<GitBranch className="w-3 h-3" />
|
||||
) : (
|
||||
<HistoryIcon className="w-3 h-3" />
|
||||
)}
|
||||
@@ -674,7 +948,7 @@ function SessionPicker({
|
||||
s.status === "active"
|
||||
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||
: s.status === "completed"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
|
||||
: s.status === "error"
|
||||
? "bg-status-danger/10 text-status-danger border-status-danger/30"
|
||||
: "bg-surface-3 text-fg-secondary border-border"
|
||||
|
||||
@@ -9,21 +9,42 @@
|
||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
||||
* without a retry the pane silently freezes on its last painted frame — it
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
/** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit).
|
||||
* There is nothing left to attach to, so the owner swaps this terminal back
|
||||
* out for its run setup form instead of leaving a dead pane on screen. */
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
// every render, and as an effect dep it would tear the terminal down and
|
||||
// re-attach on each one.
|
||||
const onExitRef = useRef(onExit);
|
||||
onExitRef.current = onExit;
|
||||
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
|
||||
// assumes a dark background. Read through a ref at construction so a theme
|
||||
// change never re-runs the attach effect; the effect below recolours live.
|
||||
const darkTheme = useTheme().themeFor.dark;
|
||||
const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]);
|
||||
const paletteRef = useRef(palette);
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -35,12 +56,21 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too.
|
||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||
// it on a PTY, where termios already does that translation for the streams
|
||||
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
|
||||
// drawing at the column it was on; with this on, that draw landed at
|
||||
// column 0 instead, stamping the first characters of the line into the
|
||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
theme: paletteRef.current,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
@@ -63,6 +93,10 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
term.focus();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
@@ -116,7 +150,16 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
let disposed = false;
|
||||
|
||||
const connect = () => {
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||
// The attach itself carries this terminal's size. tmux sizes the window
|
||||
// to the client that attached most recently, so spawning the attach at a
|
||||
// default 80×24 and correcting it from the first `resize` message a beat
|
||||
// later makes the pane's program (claude) redraw its whole UI at 80
|
||||
// columns first — and everything painted at that width stays in the
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// lane switch, and each 1.5 s reconnect.
|
||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
ws = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
@@ -143,6 +186,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
onExitRef.current?.();
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
@@ -195,8 +239,20 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||||
useEffect(() => {
|
||||
if (termRef.current) termRef.current.options.theme = palette;
|
||||
}, [palette]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
// out of claude), which the real TerminalView reports through `onExit`.
|
||||
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId}>
|
||||
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -37,6 +42,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
|
||||
const LANE: Lane = {
|
||||
id: 1,
|
||||
router_profile: null,
|
||||
title: "demo",
|
||||
cwd: "/workspace/a",
|
||||
branch: null,
|
||||
@@ -129,6 +135,35 @@ describe("LaneConsolePane", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to the setup form when the pane exits, without a New run click", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "run-1" },
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-1",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(<LaneConsolePane {...baseProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await screen.findByTestId("terminal-view");
|
||||
|
||||
fireEvent.click(screen.getByTestId("fake-pane-exit"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||
const run = (id: string, laneId: number) => ({
|
||||
@@ -194,40 +229,11 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pre-fills the prompt with the pipeline's driving skill, and keeps a typed prompt when the template changes", async () => {
|
||||
const lane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
|
||||
const props = { ...baseProps(), lanes: [lane] };
|
||||
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature "));
|
||||
|
||||
// Untouched prefill follows the template switch.
|
||||
const laneShip = { ...lane, pipeline_skill: "/agenthub:ship-feature-lane" };
|
||||
rerender(<LaneConsolePane {...props} lanes={[laneShip]} />);
|
||||
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane "));
|
||||
|
||||
// A typed requirement is never eaten by a later template switch.
|
||||
fireEvent.change(prompt, { target: { value: "/agenthub:ship-feature-lane add a button" } });
|
||||
rerender(<LaneConsolePane {...props} lanes={[lane]} />);
|
||||
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane add a button"));
|
||||
});
|
||||
|
||||
it("never pre-fills a claude slash command into a codex pane", async () => {
|
||||
// pipeline_skill is a Claude Code slash command. Codex would take it as
|
||||
// literal prose and run nothing, which is how a codex lane sits for half an
|
||||
// hour with an untouched pipeline map. No pipeline_nodes here either, so
|
||||
// there's nothing to build a stage checklist from — the box stays empty.
|
||||
const lane = { ...LANE, tool: "codex" as const, pipeline_skill: "/agenthub:ship-feature-lane" };
|
||||
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
|
||||
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(prompt.value).toBe(""));
|
||||
});
|
||||
|
||||
it("pre-fills a codex pane with a stage-declaration checklist built from its own pipeline nodes", async () => {
|
||||
// Codex has no slash commands, so it can't run pipeline_skill as-is — it
|
||||
// gets the same priming Claude gets (an opening nudge to declare stage)
|
||||
// as plain text instead, naming this lane's actual node ids.
|
||||
const lane = {
|
||||
it("never pre-fills the prompt from the lane's pipeline, for either tool", async () => {
|
||||
// Picking a pipeline used to write its driving slash command (claude) or a
|
||||
// stage checklist (codex) into the box — an extra prompt sent on start.
|
||||
const claudeLane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
|
||||
const codexLane = {
|
||||
...LANE,
|
||||
tool: "codex" as const,
|
||||
pipeline_skill: "/agenthub:ship-feature-lane",
|
||||
@@ -242,11 +248,81 @@ describe("LaneConsolePane", () => {
|
||||
{ id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const },
|
||||
],
|
||||
};
|
||||
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
|
||||
const props = { ...baseProps(), lanes: [claudeLane] };
|
||||
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(prompt.value).toContain("agenthub stage <stage> --tool codex"));
|
||||
expect(prompt.value).toContain("implement -> tests");
|
||||
expect(prompt.value).not.toContain("/agenthub:ship-feature-lane");
|
||||
await waitFor(() => expect(prompt.value).toBe(""));
|
||||
|
||||
// A typed requirement still survives a template switch.
|
||||
fireEvent.change(prompt, { target: { value: "add a button" } });
|
||||
rerender(
|
||||
<LaneConsolePane
|
||||
{...props}
|
||||
lanes={[{ ...claudeLane, pipeline_skill: "/agenthub:ship-feature-lane" }]}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(prompt.value).toBe("add a button"));
|
||||
|
||||
rerender(<LaneConsolePane {...props} lanes={[codexLane]} />);
|
||||
await waitFor(() => expect(prompt.value).toBe("add a button"));
|
||||
});
|
||||
|
||||
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
|
||||
const HISTORY_ITEM = {
|
||||
id: "run-old",
|
||||
session_id: "sess-old",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
resume_session_id: null,
|
||||
prompt_preview: "earlier prompt",
|
||||
status: "abandoned" as const,
|
||||
exit_code: null,
|
||||
started_at: new Date(1000).toISOString(),
|
||||
ended_at: new Date(2000).toISOString(),
|
||||
isLive: false,
|
||||
tool: "claude" as const,
|
||||
};
|
||||
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
// Matches the real shape `request()` throws: a plain, human-readable
|
||||
// `.message` plus a `.code` a caller actually branches on.
|
||||
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
|
||||
);
|
||||
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lanes: [{ ...LANE, run_id: "run-live" }],
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-live",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane {...baseProps()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
|
||||
const resumeButtons = await screen.findAllByRole("button", {
|
||||
name: "Resume existing session",
|
||||
});
|
||||
fireEvent.click(resumeButtons[0]!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
|
||||
);
|
||||
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||
@@ -255,4 +331,126 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
|
||||
const run = (id: string, laneId: number, promptPreview: string) => ({
|
||||
id,
|
||||
laneId,
|
||||
status: "running",
|
||||
cwd: "/workspace/x",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
});
|
||||
const session = (id: string, cwd: string, name: string) =>
|
||||
({
|
||||
id,
|
||||
cwd,
|
||||
name,
|
||||
model: null,
|
||||
source: "local",
|
||||
started_at: "2026-09-24T01:00:00Z",
|
||||
}) as never;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
lanes={[
|
||||
LANE,
|
||||
{ ...LANE, id: 2, cwd: "/workspace/b" },
|
||||
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
|
||||
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
|
||||
]}
|
||||
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
|
||||
externalSessions={[
|
||||
session("s1", "/workspace/a/src", "my session"),
|
||||
session("s2", "/workspace/b", "b session"),
|
||||
session("s3", "/workspace/a/nested/x", "nested session"),
|
||||
session("s4", "/workspace/ab", "sibling prefix"),
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
|
||||
expect(screen.getByText("mine")).toBeTruthy();
|
||||
expect(screen.getByText("my session")).toBeTruthy();
|
||||
expect(screen.queryByText("other lane's")).toBeNull();
|
||||
expect(screen.queryByText("b session")).toBeNull();
|
||||
expect(screen.queryByText("nested session")).toBeNull();
|
||||
expect(screen.queryByText("sibling prefix")).toBeNull();
|
||||
});
|
||||
|
||||
describe("split panes on one lane", () => {
|
||||
const run = (id: string, promptPreview = id) => ({
|
||||
id,
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
tool: "claude" as const,
|
||||
});
|
||||
|
||||
it("attaches a pane only to its own slot's session", async () => {
|
||||
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-2"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("starts into its own slot and shows the session the server opened", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "agenthub-lane-1" },
|
||||
run_id: "agenthub-lane-1-1",
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
|
||||
render(<LaneConsolePane {...baseProps()} slot={1} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ slot: 1 })
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-1"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves runs other panes show out of Active runs", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
slot={1}
|
||||
activeRuns={{
|
||||
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
|
||||
}}
|
||||
hiddenRunIds={["agenthub-lane-1"]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
|
||||
expect(screen.getByText("free")).toBeTruthy();
|
||||
expect(screen.queryByText("shown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
|
||||
onAttach: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onViewFromHistory: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
@@ -127,6 +128,7 @@ function renderModal(
|
||||
onAttach: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
@@ -274,6 +276,19 @@ describe("RunsModal", () => {
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
});
|
||||
|
||||
it("needs two clicks to kill a live row, and never offers Kill for an external one", () => {
|
||||
const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
|
||||
const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill"));
|
||||
fireEvent.click(killButton);
|
||||
expect(spies.onKill).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?")));
|
||||
expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID);
|
||||
unmount();
|
||||
|
||||
renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]);
|
||||
expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull();
|
||||
});
|
||||
|
||||
it("fires resume with the history item behind a finished row", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
ccConfig: {
|
||||
file: r({ text: "" }),
|
||||
commands: r({
|
||||
items: [
|
||||
{
|
||||
scope: "project",
|
||||
name: "ship-it",
|
||||
frontmatter: { description: "Ship the change" },
|
||||
preview: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
tool="claude"
|
||||
sandbox="workspace-write"
|
||||
@@ -121,8 +136,10 @@ describe("RunSetup — selections report through callbacks", () => {
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 5");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("claude-sonnet-5");
|
||||
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
|
||||
// what keeps the list from going stale on every model release.
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
@@ -234,6 +251,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no prompt when resuming, even with text in the box", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "leftover text" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
|
||||
|
||||
// The resumed session is the input; anything left in the box would be an
|
||||
// extra turn the user never asked for.
|
||||
expect(spies.onStart.mock.calls[0]?.[0]?.initialPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the typed cwd when no lane is selected", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
|
||||
@@ -277,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
tool="claude"
|
||||
@@ -341,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
|
||||
expect(onResumeLast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
|
||||
|
||||
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
// Both prefix matches; the shorter name ranks first.
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the run will start in, not the server's own.
|
||||
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
|
||||
// Enter picked the suggestion; it did not send the prompt.
|
||||
expect(spies.onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suggests files under the cwd on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the page: put the cursor at the end of it.
|
||||
const { spies } = renderSetup({ prompt: "look at @ma" });
|
||||
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
|
||||
fireEvent.select(box());
|
||||
|
||||
fireEvent.click(await screen.findByText("src/main.ts"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
|
||||
});
|
||||
|
||||
it("offers no slash commands on a codex lane", async () => {
|
||||
renderSetup({ prompt: "", tool: "codex" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
|
||||
expect(api.ccConfig.commands).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`.
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -16,6 +17,7 @@ const openMock = vi.fn();
|
||||
const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -25,9 +27,13 @@ const termState = {
|
||||
rows: 28,
|
||||
};
|
||||
const onResizeHandlers: Array<() => void> = [];
|
||||
const termOptions: { theme?: unknown } = {};
|
||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
|
||||
...(ctorArgs.push(opts), {}),
|
||||
options: termOptions,
|
||||
get modes() {
|
||||
return termState.modes;
|
||||
},
|
||||
@@ -54,8 +60,13 @@ vi.mock("@xterm/xterm", () => ({
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
vi.mock("../../../lib/terminalTheme", () => ({
|
||||
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
@@ -100,16 +111,49 @@ describe("TerminalView", () => {
|
||||
keyHandlerMock.mockClear();
|
||||
loadAddonMock.mockClear();
|
||||
openMock.mockClear();
|
||||
focusMock.mockClear();
|
||||
disposeMock.mockClear();
|
||||
ctorArgs.length = 0;
|
||||
delete termOptions.theme;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("opens a WS connection to the run's ws-pty path", () => {
|
||||
it("opens a WS connection to the run's ws-pty path, carrying its own size", () => {
|
||||
// The size rides on the attach URL, not on a `resize` message sent after
|
||||
// it: tmux resizes the window to the newest client, so attaching at the
|
||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||
// leaves that width in the scrollback (issue #33).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||
termState.cols = 0;
|
||||
termState.rows = 0;
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("paints the chosen DARK theme and recolours live without re-attaching", () => {
|
||||
localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark
|
||||
localStorage.setItem("agenthub.theme.dark", "apple-dark");
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" });
|
||||
|
||||
act(() => {
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
});
|
||||
expect(termOptions.theme).toEqual({ background: "bg-darcula" });
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(disposeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes incoming WS data to the terminal", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
@@ -184,6 +228,24 @@ describe("TerminalView", () => {
|
||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the keyboard as soon as it mounts", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// opening a lane went nowhere until the user clicked the pane.
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(focusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells its owner when the pane exited, so the setup form can come back", () => {
|
||||
// A double Ctrl+C out of `claude` ends the tmux session; the dead pane used
|
||||
// to sit there until the user found the "New run" button.
|
||||
const onExit = vi.fn();
|
||||
render(
|
||||
<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" onExit={onExit} />
|
||||
);
|
||||
MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' });
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance: the Dark / Light / System mode switch and
|
||||
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, Palette, Sun } from "lucide-react";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
"intellij-light": "IntelliJ Light",
|
||||
canvas: "Canvas",
|
||||
};
|
||||
|
||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "dark", Icon: Moon },
|
||||
{ mode: "light", Icon: Sun },
|
||||
{ mode: "system", Icon: Monitor },
|
||||
];
|
||||
|
||||
function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
|
||||
data-mode={mode}
|
||||
data-theme={name === "default" ? undefined : name}
|
||||
>
|
||||
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-accent" />
|
||||
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] leading-tight">
|
||||
<span className="text-syntax-keyword">const</span>{" "}
|
||||
<span className="text-syntax-function">x</span>{" "}
|
||||
<span className="text-syntax-operator">=</span>{" "}
|
||||
<span className="text-syntax-string">"ok"</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
|
||||
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
|
||||
return (
|
||||
<section id="appearance" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Palette className="w-4 h-4 text-fg-muted" />
|
||||
{t("appearance.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
|
||||
{MODES.map(({ mode: m, Icon }) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
aria-pressed={mode === m}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === m
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(`appearance.modes.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(["dark", "light"] as const).map((group) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t(`appearance.${group}Themes`)}
|
||||
{theme === group && (
|
||||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AppearanceSettings } from "../AppearanceSettings";
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
describe("AppearanceSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
root.classList.remove("dark");
|
||||
root.removeAttribute("data-theme");
|
||||
root.removeAttribute("data-mode");
|
||||
});
|
||||
|
||||
it("renders every theme of both modes, defaults selected", () => {
|
||||
render(<AppearanceSettings />);
|
||||
const pressed = screen
|
||||
.getAllByRole("button", { pressed: true })
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
|
||||
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("picking a dark theme applies it at once; a light pick waits for light mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("button", { name: /Darcula/ }));
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /Canvas/ }));
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /^Light$/ }));
|
||||
expect(root.classList.contains("dark")).toBe(false);
|
||||
expect(root.dataset.theme).toBe("canvas");
|
||||
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("the System mode button is pressed after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("button", { name: /System/ }));
|
||||
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
});
|
||||
|
||||
it("each swatch scopes its own mode and theme", () => {
|
||||
const { container } = render(<AppearanceSettings />);
|
||||
expect(container.querySelector('.dark[data-theme="darcula"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -151,7 +151,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x2", innerW)
|
||||
.attr("y1", (d) => yScale(d))
|
||||
.attr("y2", (d) => yScale(d))
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke", "rgb(var(--border))")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Y axis (sessions)
|
||||
@@ -166,7 +166,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
)
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
@@ -176,7 +176,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#9ca3af")
|
||||
.attr("fill", "rgb(var(--fg-secondary))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
@@ -185,7 +185,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -196,7 +196,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -229,7 +229,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", bx + bw / 2)
|
||||
.attr("y", by - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#a5b4fc")
|
||||
.attr("fill", "rgb(var(--fg-secondary))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -242,7 +242,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("y", innerH - 1)
|
||||
.attr("width", bw)
|
||||
.attr("height", 1)
|
||||
.attr("fill", "#2a2a3d");
|
||||
.attr("fill", "rgb(var(--border))");
|
||||
}
|
||||
|
||||
// Transparent, full-height hover target on top of the bar.
|
||||
@@ -364,12 +364,12 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
label={t("compaction.sessionsAffected")}
|
||||
value={affected.toLocaleString()}
|
||||
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
|
||||
accent="text-violet-300"
|
||||
accent="text-violet-700 dark:text-violet-300"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.avgPerSession")}
|
||||
value={avgPerSession.toFixed(1)}
|
||||
accent="text-blue-400"
|
||||
accent="text-blue-700 dark:text-blue-400"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.peakSession")}
|
||||
@@ -407,7 +407,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
{/* Hover tooltip (matches the app's other chart tooltips) */}
|
||||
{tip && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-xs shadow-xl"
|
||||
style={{
|
||||
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
|
||||
top: tip.y - 10,
|
||||
|
||||
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke="rgb(var(--status-success))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -174,7 +174,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
|
||||
style={{ color: isHovered ? color : "#9ca3af" }}
|
||||
style={{ color: isHovered ? color : "rgb(var(--fg-secondary))" }}
|
||||
>
|
||||
{d.count}
|
||||
</span>
|
||||
|
||||
@@ -587,7 +587,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.attr("x", (_, i) => layerXPositions[i] ?? 0)
|
||||
.attr("y", 22)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "10px")
|
||||
.attr("font-weight", "500")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
@@ -605,7 +605,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.attr("y1", 32)
|
||||
.attr("x2", sepX)
|
||||
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
|
||||
.attr("stroke", "#1f1f30")
|
||||
.attr("stroke", "rgb(var(--border))")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "4 4");
|
||||
}
|
||||
@@ -651,7 +651,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.append("path")
|
||||
.attr("d", path)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
|
||||
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
|
||||
.attr("stroke-width", isZero ? 1 : stroke)
|
||||
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
|
||||
.attr("stroke-linecap", "round");
|
||||
@@ -853,7 +853,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
|
||||
className="fixed z-50 px-3 py-2 bg-surface-2 border border-border rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
@@ -922,10 +922,11 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.style.color = "rgb(var(--fg-muted))";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.style.cssText =
|
||||
"color:rgb(var(--fg-primary));font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
@@ -935,12 +936,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
|
||||
"font-size:11px;color:rgb(var(--fg-secondary));line-height:1.45;border-top:1px solid rgb(var(--border));padding-top:8px;margin:0";
|
||||
desc.textContent = meta.description;
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.style.cssText = "font-size:10px;color:rgb(var(--fg-muted));line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function Tooltip({ state }: { state: TooltipState }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-surface-2 border border-border rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
style={{
|
||||
left: nearRight ? state.x - 12 : state.x + 12,
|
||||
top: state.y - 10,
|
||||
@@ -144,7 +144,7 @@ function Tooltip({ state }: { state: TooltipState }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
|
||||
<div className="mt-1 pt-1 border-t border-border">
|
||||
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
|
||||
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
|
||||
</span>
|
||||
@@ -262,7 +262,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
|
||||
|
||||
// Grid lines
|
||||
const gridColor = "#2a2a3d";
|
||||
const gridColor = "rgb(var(--border))";
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "grid-x")
|
||||
@@ -303,9 +303,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.tickFormat((d) => fmtXTick(d as number))
|
||||
)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// X axis label
|
||||
@@ -313,7 +313,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 44)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.duration"));
|
||||
|
||||
@@ -321,9 +321,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
g.append("g")
|
||||
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// Y axis label
|
||||
@@ -332,7 +332,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -40)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.agentCount"));
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ type AgentNode = SessionDrillInData["tree"][number];
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
return "text-violet-700 dark:text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
case "working":
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "error":
|
||||
@@ -83,7 +83,7 @@ function statusColor(status: string): string {
|
||||
case "active":
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "waiting":
|
||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
return "text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
default:
|
||||
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
|
||||
}
|
||||
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
|
||||
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
|
||||
>
|
||||
{/* Depth connector line */}
|
||||
@@ -186,7 +186,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-700 dark:text-indigo-300" : "text-fg-secondary"}`}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
@@ -252,10 +252,10 @@ function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
{events.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
|
||||
>
|
||||
{/* Tool pill */}
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
{ev.tool_name ?? ev.event_type}
|
||||
</span>
|
||||
|
||||
@@ -286,8 +286,8 @@ interface EventSequenceProps {
|
||||
const EVENT_TYPE_COLOR: Record<string, string> = {
|
||||
tool_use: "text-blue-500",
|
||||
tool_result: "text-status-success",
|
||||
agent_start: "text-indigo-400",
|
||||
agent_stop: "text-violet-400",
|
||||
agent_start: "text-indigo-700 dark:text-indigo-400",
|
||||
agent_stop: "text-violet-700 dark:text-violet-400",
|
||||
compaction: "text-status-warning",
|
||||
error: "text-status-danger",
|
||||
};
|
||||
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
|
||||
{recent.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
|
||||
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
|
||||
>
|
||||
{/* Event type badge */}
|
||||
<span
|
||||
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(s.id)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
|
||||
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
|
||||
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
|
||||
</button>
|
||||
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.close")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -121,13 +121,13 @@ function statusClass(status: string): string {
|
||||
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
|
||||
// as its own color in both the filter row and the result label chips.
|
||||
const PHASE_PALETTE = [
|
||||
"bg-violet-500/15 text-violet-300 border-violet-500/40",
|
||||
"bg-sky-500/15 text-sky-300 border-sky-500/40",
|
||||
"bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/40",
|
||||
"bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/40",
|
||||
"bg-status-warning/15 text-status-warning border-status-warning/40",
|
||||
"bg-status-success/15 text-status-success border-status-success/40",
|
||||
"bg-rose-500/15 text-rose-300 border-rose-500/40",
|
||||
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
|
||||
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
|
||||
"bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/40",
|
||||
"bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/40",
|
||||
"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/40",
|
||||
];
|
||||
|
||||
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
|
||||
@@ -321,7 +321,7 @@ export function WorkflowRunsPanel({
|
||||
if (!controlled && loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
|
||||
<Loader2 className="w-4 h-4 animate-spin text-violet-700 dark:text-violet-400" />
|
||||
<span className="animate-pulse">{t("runs.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -365,7 +365,7 @@ export function WorkflowRunsPanel({
|
||||
{running ? (
|
||||
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
|
||||
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -393,7 +393,7 @@ export function WorkflowRunsPanel({
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(run.session_id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
className="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
title={t("runs.openSession")}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
@@ -565,7 +565,7 @@ export function WorkflowRunsPanel({
|
||||
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
|
||||
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
|
||||
{ts?.loading && (
|
||||
<span className="flex items-center gap-1 text-violet-400">
|
||||
<span className="flex items-center gap-1 text-violet-700 dark:text-violet-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{t("runs.loadingFull")}
|
||||
</span>
|
||||
|
||||
@@ -71,7 +71,7 @@ function formatDurationSec(sec: number): string {
|
||||
|
||||
function successRateColor(rate: number): string {
|
||||
if (rate > 90) return "text-status-success";
|
||||
if (rate > 70) return "text-yellow-400";
|
||||
if (rate > 70) return "text-yellow-700 dark:text-yellow-400";
|
||||
return "text-status-danger";
|
||||
}
|
||||
|
||||
@@ -230,10 +230,10 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
|
||||
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-border">
|
||||
<span className="text-base font-semibold text-fg-primary tabular-nums">
|
||||
{valueDisplay}
|
||||
</span>
|
||||
@@ -325,7 +325,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.avgAgentDepth")}
|
||||
value={stats.avgDepth.toFixed(1)}
|
||||
icon={GitFork}
|
||||
accentClass="text-indigo-400"
|
||||
accentClass="text-indigo-700 dark:text-indigo-400"
|
||||
calculationKey="stats.tooltip.calc.depth"
|
||||
interp={interpAvgDepth(stats.avgDepth)}
|
||||
metricPhraseKey="stats.tooltip.phrase.depth"
|
||||
@@ -352,7 +352,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.mostCommonFlow")}
|
||||
value={topFlowLabel}
|
||||
icon={ArrowRightLeft}
|
||||
accentClass="text-violet-400"
|
||||
accentClass="text-violet-700 dark:text-violet-400"
|
||||
calculationKey="stats.tooltip.calc.topFlow"
|
||||
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
|
||||
metricPhraseKey="stats.tooltip.phrase.topFlow"
|
||||
@@ -361,7 +361,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.avgCompactions")}
|
||||
value={stats.avgCompactions.toFixed(1)}
|
||||
icon={Layers}
|
||||
accentClass="text-cyan-400"
|
||||
accentClass="text-cyan-700 dark:text-cyan-400"
|
||||
calculationKey="stats.tooltip.calc.compactions"
|
||||
interp={interpAvgCompactions(stats.avgCompactions)}
|
||||
metricPhraseKey="stats.tooltip.phrase.compactions"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @file Unit tests for useNotifications — which websocket events raise a
|
||||
* browser notification, and which preference gates each one. Covers issue #29:
|
||||
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
|
||||
* box once notifications are enabled, since those are the two moments the user
|
||||
* is the blocker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useNotifications } from "../useNotifications";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { NOTIF_KEY, defaultNotifPrefs, type NotifPrefs } from "../../lib/notifPrefs";
|
||||
import type { DashboardEvent } from "../../lib/types";
|
||||
|
||||
function enable(patch: Partial<NotifPrefs> = {}) {
|
||||
localStorage.setItem(
|
||||
NOTIF_KEY,
|
||||
JSON.stringify({ ...defaultNotifPrefs, enabled: true, ...patch })
|
||||
);
|
||||
}
|
||||
|
||||
function emit(event_type: string, summary: string) {
|
||||
eventBus.publish({
|
||||
type: "new_event",
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { event_type, summary } as DashboardEvent,
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("useNotifications", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// notify() posts to /api/push/send first and only falls back to a local
|
||||
// Notification when that throws, so the fetch spy is what we assert on.
|
||||
fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: async () => ({ ok: true, native: true, pushed: 1 }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal(
|
||||
"Notification",
|
||||
Object.assign(vi.fn(), { permission: "granted" }) as unknown as typeof Notification
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function sentTitles() {
|
||||
return fetchMock.mock.calls
|
||||
.filter((c) => c[0] === "/api/push/send")
|
||||
.map((c) => JSON.parse((c[1] as RequestInit).body as string).title as string);
|
||||
}
|
||||
|
||||
it("notifies on a confirmation request and on the end of a turn by default", async () => {
|
||||
enable();
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "Claude needs your permission to use Bash");
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
|
||||
|
||||
expect(sentTitles()[0]).toBe("Claude needs your confirmation");
|
||||
expect(sentTitles()[1]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("respects each gate independently", async () => {
|
||||
enable({ onAwaitingInput: false });
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "needs permission");
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
|
||||
expect(sentTitles()[0]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("shows a local notification when the push landed nowhere", async () => {
|
||||
// What a browser with no push subscription gets back: HTTP 200, zero
|
||||
// deliveries. Before this, the 200 alone counted as success and the user
|
||||
// saw nothing at all.
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, native: false, pushed: 0, failed: 0 }),
|
||||
});
|
||||
enable();
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(Notification).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(Notification).mock.calls[0]?.[0]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("does not double up when the push was delivered", async () => {
|
||||
enable();
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(Notification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays silent while notifications are disabled", async () => {
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "needs permission");
|
||||
emit("Stop", "Done");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(sentTitles()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @file useTheme.test.ts
|
||||
* @description Tests for useTheme — the dark/light mode hook.
|
||||
* @description Tests for useTheme — the dark/light/system mode and per-mode named-theme hook.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTheme } from "../useTheme";
|
||||
|
||||
@@ -12,9 +12,12 @@ describe("useTheme", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
document.documentElement.removeAttribute("data-mode");
|
||||
});
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("defaults to dark when localStorage is empty", () => {
|
||||
@@ -46,4 +49,165 @@ describe("useTheme", () => {
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("system mode follows prefers-color-scheme, live", () => {
|
||||
let dark = true;
|
||||
const listeners = new Set<() => void>();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
get matches() {
|
||||
return dark;
|
||||
},
|
||||
media: query,
|
||||
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
||||
}));
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setMode("system"));
|
||||
expect(result.current.mode).toBe("system");
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
act(() => {
|
||||
dark = false;
|
||||
listeners.forEach((fn) => fn());
|
||||
});
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
expect(document.documentElement.dataset.mode).toBe("light");
|
||||
});
|
||||
|
||||
it("system mode without matchMedia falls back to dark", () => {
|
||||
vi.stubGlobal("matchMedia", undefined);
|
||||
localStorage.setItem("theme", "system");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("each mode remembers its own named theme", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("light", "canvas"));
|
||||
expect(document.documentElement.dataset.theme).toBe("darcula");
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("canvas");
|
||||
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "canvas" });
|
||||
});
|
||||
|
||||
it("the default theme carries no data-theme attribute", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("dark", "default"));
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("an unknown stored theme id falls back to default", () => {
|
||||
localStorage.setItem("agenthub.theme.dark", "no-such-theme");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.themeFor.dark).toBe("default");
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("a light theme id is never applied in dark mode", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "canvas"));
|
||||
expect(result.current.themeFor.dark).toBe("default");
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("two hook instances stay in sync", () => {
|
||||
const a = renderHook(() => useTheme());
|
||||
const b = renderHook(() => useTheme());
|
||||
act(() => a.result.current.setTheme("light"));
|
||||
act(() => a.result.current.setThemeFor("light", "apple-light"));
|
||||
expect(b.result.current.theme).toBe("light");
|
||||
expect(b.result.current.themeFor.light).toBe("apple-light");
|
||||
});
|
||||
|
||||
it("another tab's storage write updates a mounted hook", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
act(() => {
|
||||
localStorage.setItem("theme", "light");
|
||||
localStorage.setItem("agenthub.theme.light", "canvas");
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "theme" }));
|
||||
});
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("canvas");
|
||||
expect(document.documentElement.dataset.mode).toBe("light");
|
||||
});
|
||||
|
||||
it("disabled storage (throwing) falls back to dark/default and setters do not crash", () => {
|
||||
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("SecurityError");
|
||||
});
|
||||
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(result.current.mode).toBe("dark");
|
||||
expect(result.current.themeFor).toEqual({ dark: "default", light: "default" });
|
||||
expect(() => act(() => result.current.setMode("light"))).not.toThrow();
|
||||
expect(() => act(() => result.current.setThemeFor("dark", "darcula"))).not.toThrow();
|
||||
expect(() => act(() => result.current.toggleTheme())).not.toThrow();
|
||||
expect(result.current.theme).toBe("dark");
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
set.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("setThemeFor with an unknown id keeps the previous pick", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("dark", "no-such-theme"));
|
||||
act(() => result.current.setThemeFor("light", ""));
|
||||
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "default" });
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
|
||||
expect(localStorage.getItem("agenthub.theme.light")).toBeNull();
|
||||
expect(document.documentElement.dataset.theme).toBe("darcula");
|
||||
});
|
||||
|
||||
it("an unknown stored mode falls back to dark", () => {
|
||||
localStorage.setItem("theme", "blue");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.mode).toBe("dark");
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.mode).toBe("dark");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, "light"],
|
||||
[false, "dark"],
|
||||
])("toggle from system (OS dark=%s) sets the explicit opposite: %s", (osDark, expected) => {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: osDark,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
localStorage.setItem("theme", "system");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.mode).toBe("system");
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(result.current.mode).toBe(expected);
|
||||
expect(result.current.theme).toBe(expected);
|
||||
expect(localStorage.getItem("theme")).toBe(expected);
|
||||
});
|
||||
|
||||
it("unmount removes the matchMedia listener", () => {
|
||||
const listeners = new Set<() => void>();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
||||
}));
|
||||
const { unmount } = renderHook(() => useTheme());
|
||||
expect(listeners.size).toBeGreaterThan(0);
|
||||
unmount();
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
* - `../i18n`
|
||||
* - `../lib/eventBus`
|
||||
* - `../lib/push`
|
||||
* - `../lib/notify`
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
@@ -48,90 +49,14 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import i18n from "../i18n";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { subscribeToPush } from "../lib/push";
|
||||
import type { WSMessage, Session, Agent, DashboardEvent } from "../lib/types";
|
||||
|
||||
const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
/** User's browser-notification preferences, persisted to `localStorage` under
|
||||
* {@link NOTIF_KEY} (written by the Settings page's notifications panel). */
|
||||
interface NotifPrefs {
|
||||
/** Master switch; when false, no notification types fire regardless of the
|
||||
* per-event flags below. */
|
||||
enabled: boolean;
|
||||
onNewSession: boolean;
|
||||
onSessionError: boolean;
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
}
|
||||
|
||||
/** Reads {@link NotifPrefs} from `localStorage`, merging over safe defaults so
|
||||
* a partial/older saved object (or none at all) still yields a valid result.
|
||||
* `enabled` defaults to false (opt-in) even in the "no saved value" branch,
|
||||
* while individual event toggles default to a sensible starting mix. */
|
||||
function loadPrefs(): NotifPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_KEY);
|
||||
if (!raw)
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
...JSON.parse(raw),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a browser notification, preferring a server-relayed push (so it can
|
||||
* arrive even if this tab isn't the active one, or the browser is backgrounded)
|
||||
* and falling back to a local service-worker/`Notification` call if the
|
||||
* server is unreachable. No-ops when the user hasn't granted permission.
|
||||
* @param title Notification title.
|
||||
* @param body Notification body text.
|
||||
*/
|
||||
async function notify(title: string, body: string) {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||
try {
|
||||
await fetch("/api/push/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body }),
|
||||
});
|
||||
} catch {
|
||||
// Server unreachable - fall back to local notification
|
||||
try {
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(title, { body, icon: "/favicon.svg", silent: false });
|
||||
} else {
|
||||
new Notification(title, { body, icon: "/favicon.svg" });
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
import { notify } from "../lib/notify";
|
||||
import { loadNotifPrefs } from "../lib/notifPrefs";
|
||||
import type { WSMessage, Session, Agent, DashboardEvent, Lane } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Wires the dashboard's {@link eventBus} up to browser notifications, per the
|
||||
@@ -143,14 +68,41 @@ async function notify(title: string, body: string) {
|
||||
* already been granted, so push delivery survives a page reload.
|
||||
*/
|
||||
export function useNotifications() {
|
||||
// A lane's `session_id` is its current/last-run session — matching on it
|
||||
// is how a click lands on the lane that actually raised the notification,
|
||||
// instead of just focusing whichever browser tab was already open.
|
||||
const lanesRef = useRef<Lane[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefs = loadPrefs();
|
||||
const prefs = loadNotifPrefs();
|
||||
if (prefs.enabled && "Notification" in window && Notification.permission === "granted") {
|
||||
subscribeToPush().catch(() => {});
|
||||
}
|
||||
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => (lanesRef.current = r.lanes))
|
||||
.catch(() => {});
|
||||
|
||||
function laneUrlForSession(sessionId: string): string | undefined {
|
||||
const lane = lanesRef.current.find((l) => l.session_id === sessionId);
|
||||
return lane ? `/workspace?lane=${lane.id}` : undefined;
|
||||
}
|
||||
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
const prefs = loadPrefs();
|
||||
if (msg.type === "lane_update") {
|
||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||
if (payload.lane) {
|
||||
const i = lanesRef.current.findIndex((l) => l.id === payload.lane!.id);
|
||||
if (i === -1) lanesRef.current = [...lanesRef.current, payload.lane];
|
||||
else lanesRef.current = lanesRef.current.map((l, idx) => (idx === i ? payload.lane! : l));
|
||||
} else if (payload.removed !== undefined) {
|
||||
lanesRef.current = lanesRef.current.filter((l) => l.id !== payload.removed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = loadNotifPrefs();
|
||||
if (!prefs.enabled) return;
|
||||
|
||||
switch (msg.type) {
|
||||
@@ -159,7 +111,8 @@ export function useNotifications() {
|
||||
const s = msg.data as Session;
|
||||
notify(
|
||||
i18n.t("errors:notifications.newSession"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -168,7 +121,8 @@ export function useNotifications() {
|
||||
if (s.status === "error" && prefs.onSessionError) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.sessionError"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -177,26 +131,39 @@ export function useNotifications() {
|
||||
if (!prefs.onSubagentSpawn) return;
|
||||
const a = msg.data as Agent;
|
||||
if (a.type === "subagent") {
|
||||
notify(i18n.t("errors:notifications.subagentSpawned"), a.name);
|
||||
notify(
|
||||
i18n.t("errors:notifications.subagentSpawned"),
|
||||
a.name,
|
||||
laneUrlForSession(a.session_id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "new_event": {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
if (ev.event_type === "Stop" && prefs.onSessionComplete) {
|
||||
const url = laneUrlForSession(ev.session_id);
|
||||
// The two states where Claude is BLOCKED on the user get their own
|
||||
// flags (issue #29): `Notification` is a permission prompt or a
|
||||
// question, `Stop` is the end of a turn. Both used to hang off
|
||||
// `onSessionComplete` / nothing at all, so the one thing the user
|
||||
// actually waits for was either off by default or unswitchable.
|
||||
if (ev.event_type === "Notification" && prefs.onAwaitingInput) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.awaitingInput"),
|
||||
ev.summary || i18n.t("errors:notifications.defaultBody"),
|
||||
url
|
||||
);
|
||||
} else if (ev.event_type === "Stop" && prefs.onTurnComplete) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.finishedResponding"),
|
||||
ev.summary || i18n.t("errors:notifications.readyForInput")
|
||||
ev.summary || i18n.t("errors:notifications.readyForInput"),
|
||||
url
|
||||
);
|
||||
} else if (ev.event_type === "SessionEnd" && prefs.onSessionComplete) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.sessionCompleted"),
|
||||
ev.summary || i18n.t("errors:notifications.sessionClosed")
|
||||
);
|
||||
} else if (ev.event_type === "Notification") {
|
||||
notify(
|
||||
i18n.t("errors:notifications.defaultTitle"),
|
||||
ev.summary || i18n.t("errors:notifications.defaultBody")
|
||||
ev.summary || i18n.t("errors:notifications.sessionClosed"),
|
||||
url
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,60 +1,135 @@
|
||||
/**
|
||||
* @file useTheme.ts
|
||||
* @description Dark/light mode state, backed by `localStorage` and the `dark`
|
||||
* class on `<html>` that Tailwind's `darkMode: "class"` reads. Default is
|
||||
* dark; there is no `prefers-color-scheme` fallback.
|
||||
* @description Appearance state: a mode (`dark` | `light` | `system`) plus one
|
||||
* named theme remembered PER mode, all backed by `localStorage`. Applies the
|
||||
* `dark` class Tailwind's `darkMode: "class"` reads, `data-mode`, and a
|
||||
* `data-theme` attribute (absent for `default`) on `<html>`; the palettes
|
||||
* themselves are CSS-variable blocks in `index.css`. State is never cached in
|
||||
* the module — every instance reads storage + `matchMedia` through
|
||||
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
|
||||
* another tab's change arrives through the `storage` event.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type ThemeMode = Theme | "system";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark"],
|
||||
light: ["default", "apple-light", "intellij-light", "canvas"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
const MODE_KEY = "theme";
|
||||
const themeKey = (mode: Theme) => `agenthub.theme.${mode}`;
|
||||
const CHANGE_EVENT = "agenthub-theme";
|
||||
const SYSTEM_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return "dark";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredTheme(theme: Theme): void {
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme): void {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
function systemQuery(): MediaQueryList | undefined {
|
||||
return typeof window.matchMedia === "function" ? window.matchMedia(SYSTEM_QUERY) : undefined;
|
||||
}
|
||||
|
||||
function readMode(): ThemeMode {
|
||||
const v = read(MODE_KEY);
|
||||
return v === "light" || v === "system" ? v : "dark";
|
||||
}
|
||||
|
||||
function readThemeFor(mode: Theme): string {
|
||||
const v = read(themeKey(mode));
|
||||
return v && (THEMES[mode] as readonly string[]).includes(v) ? v : "default";
|
||||
}
|
||||
|
||||
// A primitive snapshot: stable by value, so useSyncExternalStore never loops.
|
||||
function getSnapshot(): string {
|
||||
const mode = readMode();
|
||||
const resolved: Theme =
|
||||
mode === "system" ? (systemQuery()?.matches === false ? "light" : "dark") : mode;
|
||||
return [mode, resolved, readThemeFor("dark"), readThemeFor("light")].join("|");
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
const mq = systemQuery();
|
||||
window.addEventListener(CHANGE_EVENT, onChange);
|
||||
window.addEventListener("storage", onChange);
|
||||
mq?.addEventListener?.("change", onChange);
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange);
|
||||
window.removeEventListener("storage", onChange);
|
||||
mq?.removeEventListener?.("change", onChange);
|
||||
};
|
||||
}
|
||||
|
||||
function apply(resolved: Theme, name: string): void {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", resolved === "dark");
|
||||
root.dataset.mode = resolved;
|
||||
if (name === "default") root.removeAttribute("data-theme");
|
||||
else root.dataset.theme = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write the dashboard's active color theme.
|
||||
* @returns the current theme, a setter for an explicit value, and a toggle.
|
||||
* Read/write the dashboard's appearance.
|
||||
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
|
||||
* the user picked; `themeFor` — the named theme each mode remembers; setters
|
||||
* for each, and `toggleTheme`, which sets the explicit opposite of `theme`.
|
||||
*/
|
||||
export function useTheme(): {
|
||||
theme: Theme;
|
||||
mode: ThemeMode;
|
||||
themeFor: Record<Theme, string>;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
setThemeFor: (mode: Theme, name: string) => void;
|
||||
toggleTheme: () => void;
|
||||
} {
|
||||
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
|
||||
const [mode, resolved, dark, light] = useSyncExternalStore(subscribe, getSnapshot).split("|") as [
|
||||
ThemeMode,
|
||||
Theme,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const active = resolved === "dark" ? dark : light;
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
useLayoutEffect(() => {
|
||||
apply(resolved, active);
|
||||
}, [resolved, active]);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
writeStoredTheme(next);
|
||||
setThemeState(next);
|
||||
const setMode = useCallback((next: ThemeMode) => write(MODE_KEY, next), []);
|
||||
|
||||
const setThemeFor = useCallback((forMode: Theme, name: string) => {
|
||||
if ((THEMES[forMode] as readonly string[]).includes(name)) write(themeKey(forMode), name);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
setMode(resolved === "dark" ? "light" : "dark");
|
||||
}, [resolved, setMode]);
|
||||
|
||||
return { theme, setTheme, toggleTheme };
|
||||
return {
|
||||
theme: resolved,
|
||||
mode,
|
||||
themeFor: { dark, light },
|
||||
setTheme: setMode,
|
||||
setMode,
|
||||
setThemeFor,
|
||||
toggleTheme,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +235,14 @@
|
||||
"filterAll": "All types"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Check quota",
|
||||
"usageChecking": "Checking…",
|
||||
"usageRefresh": "Refresh",
|
||||
"usageRetry": "Try again",
|
||||
"usageFiveHour": "5h",
|
||||
"usageSevenDay": "7d",
|
||||
"usageResets": "Resets {{time}}",
|
||||
"usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.",
|
||||
"title": "Claude Code profiles",
|
||||
"openButton": "Switch profile",
|
||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
"goBack": "Go Back"
|
||||
},
|
||||
"notifications": {
|
||||
"awaitingInput": "Claude needs your confirmation",
|
||||
"defaultBody": "Notification",
|
||||
"defaultTitle": "Claude Code",
|
||||
"finishedResponding": "Claude Finished Responding",
|
||||
"newSession": "New Session",
|
||||
"readyForInput": "Ready for input",
|
||||
"sessionClosed": "Session closed",
|
||||
"sessionCompleted": "Session Completed",
|
||||
"sessionDefault": "Session ",
|
||||
"sessionError": "Session Error",
|
||||
"subagentSpawned": "Subagent Spawned",
|
||||
"finishedResponding": "Claude Finished Responding",
|
||||
"readyForInput": "Ready for input",
|
||||
"sessionCompleted": "Session Completed",
|
||||
"sessionClosed": "Session closed",
|
||||
"defaultTitle": "Claude Code",
|
||||
"defaultBody": "Notification"
|
||||
"subagentSpawned": "Subagent Spawned"
|
||||
},
|
||||
"toolLegend": {
|
||||
"read": "Read",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"action.clear": "clear state",
|
||||
"action.forget": "Delete lane",
|
||||
"action.purge": "delete history",
|
||||
"action.remove": "delete lane + worktree",
|
||||
"action.reset": "reset worktree",
|
||||
"action.start": "start",
|
||||
"action.stop": "stop",
|
||||
@@ -17,44 +16,108 @@
|
||||
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
|
||||
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
|
||||
"actions.tools": "Tools",
|
||||
"actions.writeDocs": "Create/update readme file",
|
||||
"actions.writeDocsHint": "Opens this lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
|
||||
"actions.writeDocsResult": "Started — see this lane's terminal.",
|
||||
"add": "Add lane",
|
||||
"addLane": "Create a lane from a working directory",
|
||||
"addLaneBaseLabel": "Branch to fork from",
|
||||
"addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.",
|
||||
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
|
||||
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
|
||||
"addLaneBranchExistingLabel": "Branch to check out",
|
||||
"addLaneBranchHintAuto": "Leave empty to let the server name the branch from the title.",
|
||||
"addLaneBranchLabel": "New branch name",
|
||||
"addLaneBranchPlaceholder": "feat/my-feature",
|
||||
"addLaneBranchModeCurrent": "Current branch",
|
||||
"addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.",
|
||||
"addLaneBranchModeExisting": "Existing branch",
|
||||
"addLaneBranchModeNew": "New branch",
|
||||
"addLaneBranchPlaceholder": "Automatic — named from the title",
|
||||
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
||||
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
|
||||
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
|
||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
||||
"addLanePipelineHint": "Which stages this lane's map shows. Pick the one the skill driving it declares against — nothing else in this UI changes it later.",
|
||||
"addLanePipelineLabel": "Pipeline template",
|
||||
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
|
||||
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
|
||||
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
|
||||
"addLaneRepoLabel": "Source repository",
|
||||
"addLaneRepoLabelAdopt": "Directory",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
|
||||
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
|
||||
"addLaneRouterLabel": "Model endpoint",
|
||||
"addLaneRouterLoading": "Loading router profiles…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneSetupAgents": "Agents",
|
||||
"addLaneSetupClose": "Close",
|
||||
"addLaneSetupMcp": "MCP servers",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
"addLaneSetupRouter": "Model endpoint",
|
||||
"addLaneSetupTitle": "Setup",
|
||||
"addLaneTitleLabel": "Title",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there. Claude lanes only — codex has no slash commands.",
|
||||
"addLaneWriteDocsLabel": "Create/update readme file",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} more",
|
||||
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
|
||||
"browse.button": "Browse",
|
||||
"browse.empty": "This folder is empty.",
|
||||
"browse.select": "Select this folder",
|
||||
"browse.title": "Browse for a folder",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"cardId": "Lane {{id}}",
|
||||
"changes.back": "All files",
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "Binary file — not shown.",
|
||||
"changes.collapse": "Collapse the changes panel",
|
||||
"changes.collapseAll": "Collapse every file",
|
||||
"changes.collapseAllFolders": "Collapse all folders",
|
||||
"changes.collapseContext": "Show fewer surrounding lines",
|
||||
"changes.collapseFile": "Collapse {{path}}",
|
||||
"changes.commitPlaceholder": "Commit message",
|
||||
"changes.commitStaged": "Commit {{count}} staged",
|
||||
"changes.conflicted": "conflict",
|
||||
"changes.discard": "Discard this file's changes",
|
||||
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
||||
"changes.empty.branch": "This branch matches its base.",
|
||||
"changes.empty.commit": "This commit changed no files.",
|
||||
"changes.empty.work": "Nothing uncommitted.",
|
||||
"changes.expand": "Show the changes panel",
|
||||
"changes.expandAll": "Expand every file",
|
||||
"changes.expandAllFolders": "Expand all folders",
|
||||
"changes.expandContext": "Show more surrounding lines",
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||
"changes.layout.split": "Side-by-side",
|
||||
"changes.layout.unified": "Unified",
|
||||
"changes.loading": "Reading the working copy…",
|
||||
"changes.mode.browse": "Files",
|
||||
"changes.mode.history": "History",
|
||||
"changes.mode.overview": "Overview",
|
||||
"changes.mode.review": "Review",
|
||||
"changes.noCommits": "No commits yet.",
|
||||
"changes.noHunks": "No textual change.",
|
||||
"changes.noMatches": "No tracked file matches.",
|
||||
"changes.openFile": "Open {{path}}",
|
||||
"changes.quickOpen": "Find a file by path…",
|
||||
"changes.range.branch": "vs base",
|
||||
"changes.range.branchHint": "base...HEAD — everything this lane's branch adds on top of its base",
|
||||
"changes.range.work": "Uncommitted",
|
||||
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
||||
"changes.refresh": "Refresh",
|
||||
"changes.resize": "Resize the changes panel (next: {{size}})",
|
||||
"changes.size.compact": "Narrow",
|
||||
"changes.size.half": "Medium",
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.stage": "Stage this file",
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
"changes.unavailable": "This lane's directory is not a readable git repository.",
|
||||
"changes.unstage": "Unstage this file",
|
||||
"changes.wholeFile": "Whole file",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
"confirmRemoveMessage": "This action cannot be undone.",
|
||||
@@ -79,33 +142,76 @@
|
||||
"destructive.loading": "Loading current facts…",
|
||||
"destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.",
|
||||
"destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.",
|
||||
"destructive.notice.missing": "The lane directory is already gone. Removing it drops the dashboard's record and clears Git's stale worktree entry; nothing is deleted from disk.",
|
||||
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched.",
|
||||
"destructive.notice.missing": "The lane directory is already gone. Deleting the lane drops the dashboard's record only; nothing on disk and no Git record is touched.",
|
||||
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. That changes nothing here: only the dashboard's record is deleted, the directory is never touched.",
|
||||
"destructive.purge.confirm": "Purge history",
|
||||
"destructive.purge.message": "Approve the current counts before deleting stored session history.",
|
||||
"destructive.purge.title": "Purge lane history?",
|
||||
"destructive.remove.confirm": "Remove lane",
|
||||
"destructive.remove.message": "Approve the current counts before this action.",
|
||||
"destructive.remove.title": "Remove lane?",
|
||||
"destructive.remove.confirm": "Delete lane record",
|
||||
"destructive.remove.message": "Only the dashboard's record of this lane is deleted. Its working tree, its branch and its database are left exactly as they are — delete them yourself if you want them gone.",
|
||||
"destructive.remove.title": "Delete lane record?",
|
||||
"destructive.reset.confirm": "Reset worktree",
|
||||
"destructive.reset.message": "Approve the current counts before resetting this worktree.",
|
||||
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
|
||||
"destructive.reset.title": "Reset managed worktree?",
|
||||
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
||||
"emptyState": "No lanes yet. Create one from a working directory:",
|
||||
"empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
|
||||
"empty.title": "No lanes yet",
|
||||
"features.archived": "archived",
|
||||
"features.live": "Live",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||
"git.filesHeading_one": "{{count}} file changed",
|
||||
"git.filesHeading_other": "{{count}} files changed",
|
||||
"git.filesMore_one": "… and {{count}} more file",
|
||||
"git.filesMore_other": "… and {{count}} more files",
|
||||
"git.noDelta": "new",
|
||||
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||
"gitBar.branchCreate": "Create",
|
||||
"gitBar.branchCreateHint": "Create this branch without switching to it",
|
||||
"gitBar.branchDelete": "Delete",
|
||||
"gitBar.branchDeleteHint": "Delete the selected branch (refused if it has unmerged commits)",
|
||||
"gitBar.branchNewPlaceholder": "New branch name…",
|
||||
"gitBar.fetch": "Fetch",
|
||||
"gitBar.fetchHint": "git fetch --all --prune",
|
||||
"gitBar.errNoRemote": "No remote to push to or pull from. Add one, or set this branch's upstream, in a terminal.",
|
||||
"gitBar.inSync": "in sync",
|
||||
"gitBar.newLane": "New lane here",
|
||||
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
|
||||
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
|
||||
"gitBar.noUpstream": "not pushed",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||
"gitBar.push": "Push",
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
"gitBar.switchCurrent": "current",
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
"preflightError": "Could not load the current lane facts.",
|
||||
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
|
||||
"proof.ticketReport": "Task report",
|
||||
"renameLabel": "Rename this lane",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"router.applied": "Applied.",
|
||||
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
|
||||
"router.label": "Model",
|
||||
"router.restart": "Restart",
|
||||
"router.restarted": "Session restarted with the new router.",
|
||||
"runtime.boot": "▶ up",
|
||||
"runtime.busy.down": "stopping…",
|
||||
"runtime.busy.up": "booting…",
|
||||
@@ -116,23 +222,33 @@
|
||||
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||
"runtime.stop": "■ down",
|
||||
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
|
||||
"integrations.ci_wait": "CI wait",
|
||||
"integrations.dev_qc": "dev QC",
|
||||
"integrations.tracker": "tracker",
|
||||
"stageUndeclared": "not declared",
|
||||
"splitView.emptyPane": "No lane selected for this pane.",
|
||||
"splitView.paneCount": "{{count}} pane",
|
||||
"splitView.paneLaneLabel": "Pane lane selector",
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"status.failed": "failed",
|
||||
"status.idle": "idle",
|
||||
"status.provisioning": "provisioning",
|
||||
"status.running": "running",
|
||||
"features.live": "Live",
|
||||
"features.archived": "archived",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||
"proof.ticketReport": "Task report",
|
||||
"splitView.emptyPane": "No lane selected for this pane.",
|
||||
"splitView.paneLaneLabel": "Pane lane selector",
|
||||
"splitView.paneCount": "{{count}} pane",
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"statusDead": "DEAD",
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"changes.stageHunk": "Stage this hunk",
|
||||
"changes.undoHunk": "Undo this hunk",
|
||||
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
|
||||
"changes.stageHunkShort": "Keep",
|
||||
"changes.undoHunkShort": "Undo",
|
||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.editorLabel": "Edit {{path}}",
|
||||
"changes.prevHunk": "Previous change",
|
||||
"changes.nextHunk": "Next change",
|
||||
"changes.hunkOf": "{{n}} of {{total}}",
|
||||
"changes.noEditBinary": "Binary file — not editable here",
|
||||
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
|
||||
"changes.noEdit": "Not editable here"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"agentBoard": "Kanban Board",
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
"brand": "Agent Dashboard",
|
||||
"brand": "AgentHub",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Claude Config",
|
||||
"checkFailed": "Update check failed",
|
||||
@@ -54,7 +54,8 @@
|
||||
"theme": "Theme",
|
||||
"themeNames": {
|
||||
"dark": "Dark",
|
||||
"light": "Light"
|
||||
"light": "Light",
|
||||
"system": "System"
|
||||
},
|
||||
"throughput60s": "Throughput · last 60s",
|
||||
"topEventTypes": "Top event types",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"attachFailed": "Couldn't attach to run: {{message}}",
|
||||
"attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
|
||||
"cwdRequired": "Working directory required to start a run",
|
||||
"killFailed": "Couldn't stop: {{message}}",
|
||||
"laneCreateFailed": "Failed to create lane: {{message}}",
|
||||
@@ -61,6 +62,7 @@
|
||||
"cwdGroups": {
|
||||
"dashboard": "Dashboard server",
|
||||
"home": "Home",
|
||||
"repo": "Source repos",
|
||||
"recent": "Recent - used by Claude Code"
|
||||
},
|
||||
"cwdHint": "Absolute path. Defaults to the dashboard's own cwd.",
|
||||
@@ -71,7 +73,8 @@
|
||||
"modelCustom": "Custom…",
|
||||
"modelCustomPlaceholder": "model id (e.g. claude-opus-5)",
|
||||
"modelInheritLabel": "Inherit from settings",
|
||||
"permissionAcceptEdits": "acceptEdits (recommended)",
|
||||
"permissionAcceptEdits": "acceptEdits (file edits without asking)",
|
||||
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
||||
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||
"permissionMode": "Permission mode",
|
||||
@@ -95,7 +98,7 @@
|
||||
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
|
||||
"sandboxWarning": "Codex will run commands with no sandbox.",
|
||||
"shortcut": "Cmd+Enter / Ctrl+Enter to send"
|
||||
"shortcut": "Enter to send · Shift+Enter for newline"
|
||||
},
|
||||
"limitations": {
|
||||
"dismiss": "Got it"
|
||||
@@ -153,7 +156,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Spin up a {{tool}} session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.",
|
||||
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
|
||||
"title": "Run {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -71,21 +71,27 @@
|
||||
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"awaitingInput": "Claude needs confirmation",
|
||||
"blocked": "Permission blocked",
|
||||
"description": "Browser notifications for important events. Requires permission.",
|
||||
"disabledInfo": "Notifications are disabled - enable to get alerts for session events",
|
||||
"enable": "Enable Browser Notifications",
|
||||
"granted": "Permission granted",
|
||||
"blocked": "Permission blocked",
|
||||
"required": "Permission required",
|
||||
"notifyWhen": "Notify me when...",
|
||||
"newSession": "New session starts",
|
||||
"notifyWhen": "Notify me when...",
|
||||
"required": "Permission required",
|
||||
"sendTest": "Send Test Notification",
|
||||
"sessionComplete": "Session completes",
|
||||
"sessionError": "Session errors",
|
||||
"subagentSpawned": "Subagent spawned",
|
||||
"testTitle": "Agent Monitor",
|
||||
"testBlocked": "The browser has not granted notification permission.",
|
||||
"testBody": "Notifications are working!",
|
||||
"sendTest": "Send Test Notification",
|
||||
"disabledInfo": "Notifications are disabled - enable to get alerts for session events"
|
||||
"testFailed": "Nothing could be shown.",
|
||||
"testLocal": "Your browser has no push subscription, so this was shown locally. Web Push needs a secure origin (localhost or HTTPS) and a registered service worker.",
|
||||
"testSent": "Sent via push — check your notification centre.",
|
||||
"testTitle": "Agent Monitor",
|
||||
"title": "Notifications",
|
||||
"turnComplete": "Claude finishes a turn"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import History",
|
||||
@@ -445,6 +451,63 @@
|
||||
"label": "Tools",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a lane.",
|
||||
"add": "Add profile",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
|
||||
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Saved",
|
||||
"keyMissing": "None",
|
||||
"fieldLabel": "Name",
|
||||
"fieldLabelHint": "What you will see in the lane form. Free text.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Small / fast",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.",
|
||||
"loading": "Loading profiles…",
|
||||
"lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}",
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "No answer ({{status}}): {{message}}"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only.",
|
||||
"mode": "Mode",
|
||||
"modes": {
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"system": "System"
|
||||
},
|
||||
"darkThemes": "Dark themes",
|
||||
"lightThemes": "Light themes",
|
||||
"active": "· in use now",
|
||||
"default": "Default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
|
||||
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
|
||||
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover."
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
|
||||
"restartingShort": "Restarting…"
|
||||
}
|
||||
|
||||
@@ -230,6 +230,14 @@
|
||||
"filterAll": "Tất cả loại"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Kiểm tra quota",
|
||||
"usageChecking": "Đang kiểm tra…",
|
||||
"usageRefresh": "Làm mới",
|
||||
"usageRetry": "Thử lại",
|
||||
"usageFiveHour": "5h",
|
||||
"usageSevenDay": "7 ngày",
|
||||
"usageResets": "Reset lúc {{time}}",
|
||||
"usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.",
|
||||
"title": "Profile Claude Code",
|
||||
"openButton": "Đổi profile",
|
||||
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
"goBack": "Quay lại"
|
||||
},
|
||||
"notifications": {
|
||||
"awaitingInput": "Claude cần bạn xác nhận",
|
||||
"defaultBody": "Thông báo",
|
||||
"defaultTitle": "Claude Code",
|
||||
"finishedResponding": "Claude đã phản hồi xong",
|
||||
"newSession": "Phiên mới",
|
||||
"readyForInput": "Sẵn sàng nhận đầu vào",
|
||||
"sessionClosed": "Phiên đã đóng",
|
||||
"sessionCompleted": "Phiên đã hoàn tất",
|
||||
"sessionDefault": "Phiên ",
|
||||
"sessionError": "Lỗi phiên",
|
||||
"subagentSpawned": "Đã tạo Subagent",
|
||||
"finishedResponding": "Claude đã phản hồi xong",
|
||||
"readyForInput": "Sẵn sàng nhận đầu vào",
|
||||
"sessionCompleted": "Phiên đã hoàn tất",
|
||||
"sessionClosed": "Phiên đã đóng",
|
||||
"defaultTitle": "Claude Code",
|
||||
"defaultBody": "Thông báo"
|
||||
"subagentSpawned": "Đã tạo Subagent"
|
||||
},
|
||||
"toolLegend": {
|
||||
"read": "Đọc",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"action.clear": "Dọn trạng thái",
|
||||
"action.forget": "Xóa lane",
|
||||
"action.purge": "Xóa lịch sử",
|
||||
"action.remove": "Xóa làn + worktree",
|
||||
"action.reset": "Đặt lại worktree",
|
||||
"action.start": "Bắt đầu",
|
||||
"action.stop": "Dừng",
|
||||
@@ -17,44 +16,108 @@
|
||||
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
|
||||
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
|
||||
"actions.tools": "Công cụ",
|
||||
"actions.writeDocs": "Tạo/cập nhật file readme",
|
||||
"actions.writeDocsHint": "Mở terminal của lane này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
|
||||
"actions.writeDocsResult": "Đã chạy — xem terminal của lane này.",
|
||||
"add": "Thêm lane",
|
||||
"addLane": "Tạo lane từ một thư mục làm việc",
|
||||
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
||||
"addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.",
|
||||
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
|
||||
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
|
||||
"addLaneBranchExistingLabel": "Nhánh cần checkout",
|
||||
"addLaneBranchHintAuto": "Để trống để server tự đặt tên nhánh theo tiêu đề.",
|
||||
"addLaneBranchLabel": "Tên nhánh mới",
|
||||
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
|
||||
"addLaneBranchModeCurrent": "Nhánh hiện tại",
|
||||
"addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.",
|
||||
"addLaneBranchModeExisting": "Nhánh có sẵn",
|
||||
"addLaneBranchModeNew": "Nhánh mới",
|
||||
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
|
||||
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
|
||||
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
|
||||
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
|
||||
"addLanePipelineHint": "Bản đồ lane này sẽ hiện những stage nào. Chọn đúng cái mà skill điều khiển nó khai báo — sau này không màn hình nào đổi được.",
|
||||
"addLanePipelineLabel": "Mẫu pipeline",
|
||||
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
|
||||
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
|
||||
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
|
||||
"addLaneRepoLabel": "Repo nguồn",
|
||||
"addLaneRepoLabelAdopt": "Thư mục",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
|
||||
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
|
||||
"addLaneRouterLabel": "Endpoint model",
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneSetupAgents": "Agent",
|
||||
"addLaneSetupClose": "Đóng",
|
||||
"addLaneSetupMcp": "MCP server",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
"addLaneSetupRouter": "Endpoint model",
|
||||
"addLaneSetupTitle": "Thiết lập",
|
||||
"addLaneTitleLabel": "Tiêu đề",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó. Chỉ áp dụng cho lane Claude — codex không có slash command.",
|
||||
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
|
||||
"browse.button": "Duyệt",
|
||||
"browse.empty": "Thư mục này trống.",
|
||||
"browse.select": "Chọn thư mục này",
|
||||
"browse.title": "Duyệt chọn thư mục",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"cardId": "Làn đường {{id}}",
|
||||
"changes.back": "Tất cả file",
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "File nhị phân — không hiển thị.",
|
||||
"changes.collapse": "Thu gọn bảng thay đổi",
|
||||
"changes.collapseAll": "Thu gọn tất cả file",
|
||||
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
|
||||
"changes.collapseContext": "Bớt dòng xung quanh",
|
||||
"changes.collapseFile": "Thu gọn {{path}}",
|
||||
"changes.commitPlaceholder": "Nội dung commit",
|
||||
"changes.commitStaged": "Commit {{count}} file đã stage",
|
||||
"changes.conflicted": "xung đột",
|
||||
"changes.discard": "Bỏ thay đổi của file này",
|
||||
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
|
||||
"changes.empty.branch": "Nhánh này không khác nhánh gốc.",
|
||||
"changes.empty.commit": "Commit này không đổi file nào.",
|
||||
"changes.empty.work": "Không có gì chưa commit.",
|
||||
"changes.expand": "Mở bảng thay đổi",
|
||||
"changes.expandAll": "Mở tất cả file",
|
||||
"changes.expandAllFolders": "Mở tất cả thư mục",
|
||||
"changes.expandContext": "Hiện thêm dòng xung quanh",
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||
"changes.layout.split": "Hai cột",
|
||||
"changes.layout.unified": "Một cột",
|
||||
"changes.loading": "Đang đọc working copy…",
|
||||
"changes.mode.browse": "Tệp",
|
||||
"changes.mode.history": "Lịch sử",
|
||||
"changes.mode.overview": "Tổng quan",
|
||||
"changes.mode.review": "Xem toàn bộ",
|
||||
"changes.noCommits": "Chưa có commit nào.",
|
||||
"changes.noHunks": "Không có thay đổi nội dung.",
|
||||
"changes.noMatches": "Không có file nào khớp.",
|
||||
"changes.openFile": "Mở {{path}}",
|
||||
"changes.quickOpen": "Tìm file theo đường dẫn…",
|
||||
"changes.range.branch": "So nhánh gốc",
|
||||
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của lane này thêm vào so với nhánh gốc",
|
||||
"changes.range.work": "Chưa commit",
|
||||
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
||||
"changes.refresh": "Tải lại",
|
||||
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
|
||||
"changes.size.compact": "Hẹp",
|
||||
"changes.size.half": "Vừa",
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.stage": "Đưa file này vào index",
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
"changes.unavailable": "Thư mục của lane này không phải repo git đọc được.",
|
||||
"changes.unstage": "Bỏ file này khỏi index",
|
||||
"changes.wholeFile": "Toàn bộ file",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||
@@ -78,34 +141,77 @@
|
||||
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.",
|
||||
"destructive.loading": "Đang tải trạng thái hiện tại…",
|
||||
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.",
|
||||
"destructive.notice.adopted": "Làn đường này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
|
||||
"destructive.notice.missing": "Thư mục làn đường đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển và dọn mục worktree cũ trong Git; không tệp nào bị xóa khỏi đĩa.",
|
||||
"destructive.notice.unreadable": "Không thể đọc thư mục làn đường như một Git worktree. Hệ thống sẽ thử xóa; nếu Git tự chối, mục ghi worktree của nó sẽ được dọn trực tiếp thay thế. Trong mọi trường hợp, thư mục vẫn không bị thay đổi.",
|
||||
"destructive.notice.adopted": "Lane này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
|
||||
"destructive.notice.missing": "Thư mục lane đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển; không tệp nào trên đĩa và không bản ghi Git nào bị đụng tới.",
|
||||
"destructive.notice.unreadable": "Không thể đọc thư mục lane như một Git worktree. Điều đó không ảnh hưởng gì: chỉ bản ghi trên bảng điều khiển bị xóa, thư mục không bao giờ bị đụng tới.",
|
||||
"destructive.purge.confirm": "Xóa lịch sử",
|
||||
"destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.",
|
||||
"destructive.purge.title": "Xóa lịch sử làn đường?",
|
||||
"destructive.remove.confirm": "Xóa làn đường",
|
||||
"destructive.remove.message": "Hãy xác nhận các số liệu hiện tại trước khi thực hiện thao tác này.",
|
||||
"destructive.remove.title": "Xóa làn đường?",
|
||||
"destructive.remove.confirm": "Xóa bản ghi lane",
|
||||
"destructive.remove.message": "Chỉ bản ghi của lane trên bảng điều khiển bị xóa. Working tree, nhánh và cơ sở dữ liệu của lane được giữ nguyên — bạn tự xóa nếu thực sự muốn bỏ chúng.",
|
||||
"destructive.remove.title": "Xóa bản ghi lane?",
|
||||
"destructive.reset.confirm": "Đặt lại worktree",
|
||||
"destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.",
|
||||
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
|
||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
||||
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
||||
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
|
||||
"empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
|
||||
"empty.title": "Chưa có lane nào",
|
||||
"features.archived": "đã lưu trữ",
|
||||
"features.live": "Phiên bản trực tiếp",
|
||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||
"git.filesHeading_one": "{{count}} tệp thay đổi",
|
||||
"git.filesHeading_other": "{{count}} tệp thay đổi",
|
||||
"git.filesMore_one": "… và {{count}} tệp nữa",
|
||||
"git.filesMore_other": "… và {{count}} tệp nữa",
|
||||
"git.noDelta": "mới",
|
||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||
"gitBar.branchCreate": "Tạo",
|
||||
"gitBar.branchCreateHint": "Tạo nhánh này mà không chuyển sang",
|
||||
"gitBar.branchDelete": "Xoá",
|
||||
"gitBar.branchDeleteHint": "Xoá nhánh đang chọn (git từ chối nếu còn commit chưa merge)",
|
||||
"gitBar.branchNewPlaceholder": "Tên nhánh mới…",
|
||||
"gitBar.fetch": "Fetch",
|
||||
"gitBar.fetchHint": "git fetch --all --prune",
|
||||
"gitBar.errNoRemote": "Không có remote để push hoặc pull. Hãy thêm remote, hoặc đặt upstream cho nhánh này, trong terminal.",
|
||||
"gitBar.inSync": "đã đồng bộ",
|
||||
"gitBar.newLane": "Tạo lane mới",
|
||||
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
|
||||
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
|
||||
"gitBar.noUpstream": "chưa push",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||
"gitBar.push": "Push",
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Nhánh",
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
"gitBar.switchCurrent": "hiện tại",
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
"laneDetail.show": "Chi tiết lane",
|
||||
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
|
||||
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
|
||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||
"renameLabel": "Đổi tên làn đường này",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"router.applied": "Đã áp dụng.",
|
||||
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
|
||||
"router.label": "Model",
|
||||
"router.restart": "Khởi động lại",
|
||||
"router.restarted": "Đã khởi động lại phiên với router mới.",
|
||||
"runtime.boot": "▶ chạy",
|
||||
"runtime.busy.down": "đang dừng…",
|
||||
"runtime.busy.up": "đang khởi động…",
|
||||
@@ -116,23 +222,33 @@
|
||||
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
||||
"runtime.stop": "■ dừng",
|
||||
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
|
||||
"integrations.ci_wait": "CI wait",
|
||||
"integrations.dev_qc": "dev QC",
|
||||
"integrations.tracker": "tracker",
|
||||
"stageUndeclared": "chưa khai báo",
|
||||
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
||||
"splitView.paneCount": "{{count}} ô",
|
||||
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"status.failed": "thất bại",
|
||||
"status.idle": "rảnh",
|
||||
"status.provisioning": "đang khởi tạo",
|
||||
"status.running": "đang chạy",
|
||||
"features.live": "Phiên bản trực tiếp",
|
||||
"features.archived": "đã lưu trữ",
|
||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
||||
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
||||
"splitView.paneCount": "{{count}} ô",
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"statusDead": "ĐÃ CHẾT",
|
||||
"title": "Làn đường",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"changes.stageHunk": "Stage đoạn này",
|
||||
"changes.undoHunk": "Hoàn tác đoạn này",
|
||||
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục",
|
||||
"changes.stageHunkShort": "Giữ",
|
||||
"changes.undoHunkShort": "Hoàn tác",
|
||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.editorLabel": "Sửa {{path}}",
|
||||
"changes.prevHunk": "Thay đổi trước",
|
||||
"changes.nextHunk": "Thay đổi sau",
|
||||
"changes.hunkOf": "{{n}} / {{total}}",
|
||||
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
|
||||
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
|
||||
"changes.noEdit": "Không sửa được ở đây"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"agentBoard": "Bảng Kanban",
|
||||
"analytics": "Phân tích",
|
||||
"avg": "TB",
|
||||
"brand": "Bảng điều khiển Agent",
|
||||
"brand": "AgentHub",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Cấu hình Claude",
|
||||
"checkFailed": "Kiểm tra cập nhật thất bại",
|
||||
@@ -54,7 +54,8 @@
|
||||
"theme": "Giao diện",
|
||||
"themeNames": {
|
||||
"dark": "Tối",
|
||||
"light": "Sáng"
|
||||
"light": "Sáng",
|
||||
"system": "Theo hệ thống"
|
||||
},
|
||||
"throughput60s": "Lưu lượng · 60s qua",
|
||||
"topEventTypes": "Loại sự kiện phổ biến",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"attachFailed": "Không gắn được vào run: {{message}}",
|
||||
"attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
|
||||
"cwdRequired": "Cần thư mục làm việc để bắt đầu run",
|
||||
"killFailed": "Không dừng được: {{message}}",
|
||||
"laneCreateFailed": "Không thể tạo làn đường: {{message}}",
|
||||
@@ -60,6 +61,7 @@
|
||||
"cwdGroups": {
|
||||
"dashboard": "Dashboard server",
|
||||
"home": "Home",
|
||||
"repo": "Repo nguồn",
|
||||
"recent": "Gần đây - Claude Code đã dùng"
|
||||
},
|
||||
"cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.",
|
||||
@@ -70,7 +72,8 @@
|
||||
"modelCustom": "Tuỳ chỉnh…",
|
||||
"modelCustomPlaceholder": "model id (vd: claude-opus-5)",
|
||||
"modelInheritLabel": "Kế thừa từ settings",
|
||||
"permissionAcceptEdits": "acceptEdits (khuyên dùng)",
|
||||
"permissionAcceptEdits": "acceptEdits (sửa file không hỏi)",
|
||||
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
||||
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||
"permissionMode": "Permission mode",
|
||||
@@ -94,7 +97,7 @@
|
||||
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
|
||||
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
|
||||
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
|
||||
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
||||
},
|
||||
"limitations": {
|
||||
"dismiss": "Đã rõ"
|
||||
@@ -152,7 +155,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Khởi chạy một phiên {{tool}} ngay trong dashboard. Stream output trực tiếp, hội thoại đa lượt, và chia sẻ cùng pipeline hooks/analytics với phiên ở terminal.",
|
||||
"subtitle": "Stream trực tiếp, hội thoại đa lượt, cùng hooks và analytics như ở terminal.",
|
||||
"title": "Chạy {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -71,21 +71,27 @@
|
||||
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Thông báo",
|
||||
"awaitingInput": "Claude cần xác nhận",
|
||||
"blocked": "Đã chặn quyền",
|
||||
"description": "Thông báo trình duyệt cho các sự kiện quan trọng. Cần cấp quyền.",
|
||||
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên",
|
||||
"enable": "Bật thông báo trình duyệt",
|
||||
"granted": "Đã cấp quyền",
|
||||
"blocked": "Đã chặn quyền",
|
||||
"required": "Cần cấp quyền",
|
||||
"notifyWhen": "Thông báo cho tôi khi...",
|
||||
"newSession": "Phiên mới bắt đầu",
|
||||
"notifyWhen": "Thông báo cho tôi khi...",
|
||||
"required": "Cần cấp quyền",
|
||||
"sendTest": "Gửi thông báo thử",
|
||||
"sessionComplete": "Phiên hoàn tất",
|
||||
"sessionError": "Phiên gặp lỗi",
|
||||
"subagentSpawned": "Subagent được tạo",
|
||||
"testTitle": "Agent Monitor",
|
||||
"testBlocked": "Trình duyệt chưa cấp quyền thông báo.",
|
||||
"testBody": "Thông báo đang hoạt động!",
|
||||
"sendTest": "Gửi thông báo thử",
|
||||
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên"
|
||||
"testFailed": "Không hiển thị được thông báo nào.",
|
||||
"testLocal": "Trình duyệt chưa đăng ký push nên thông báo được hiện tại chỗ. Web Push cần origin bảo mật (localhost hoặc HTTPS) và service worker đã đăng ký.",
|
||||
"testSent": "Đã gửi qua push — kiểm tra trung tâm thông báo.",
|
||||
"testTitle": "Agent Monitor",
|
||||
"title": "Thông báo",
|
||||
"turnComplete": "Claude trả lời xong"
|
||||
},
|
||||
"import": {
|
||||
"title": "Nhập lịch sử",
|
||||
@@ -445,6 +451,63 @@
|
||||
"label": "Công cụ",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo lane.",
|
||||
"add": "Thêm profile",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
|
||||
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Đã lưu",
|
||||
"keyMissing": "Chưa có",
|
||||
"fieldLabel": "Tên",
|
||||
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo lane. Gõ tự do.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Nhỏ / nhanh",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
|
||||
"loading": "Đang tải danh sách profile…",
|
||||
"lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}",
|
||||
"test": "Kiểm tra",
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "Không trả lời ({{status}}): {{message}}"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
|
||||
"mode": "Chế độ",
|
||||
"modes": {
|
||||
"dark": "Tối",
|
||||
"light": "Sáng",
|
||||
"system": "Theo hệ thống"
|
||||
},
|
||||
"darkThemes": "Theme tối",
|
||||
"lightThemes": "Theme sáng",
|
||||
"active": "· đang dùng",
|
||||
"default": "Mặc định"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
|
||||
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
|
||||
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục."
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
|
||||
"restartingShort": "Đang khởi động lại…"
|
||||
}
|
||||
|
||||
+261
-24
@@ -15,35 +15,86 @@
|
||||
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
|
||||
* 3-5 component background 9-10 solid (buttons, vibrant fills)
|
||||
* 11-12 text (11 secondary, 12 primary)
|
||||
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/
|
||||
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
|
||||
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
|
||||
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
|
||||
* and terracotta, because a cool near-white page reads as glare. `green`/
|
||||
* `red`/`amber` step 11 supplies the status tokens — the same step used for
|
||||
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
|
||||
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
|
||||
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
|
||||
*/
|
||||
:root {
|
||||
:root,
|
||||
[data-mode="light"] {
|
||||
/* Light theme — the CSS default, so it applies with no class on <html>.
|
||||
Radix `slate` (light), steps 1/2/3/4/5/6/7. */
|
||||
--surface-0: 252 252 253; /* slate-1 #fcfcfd */
|
||||
--surface-1: 249 249 251; /* slate-2 #f9f9fb */
|
||||
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */
|
||||
--surface-3: 232 232 236; /* slate-4 #e8e8ec */
|
||||
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */
|
||||
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
|
||||
--border: 217 217 224; /* slate-6 #d9d9e0 */
|
||||
--border-light: 205 206 214; /* slate-7 #cdced6 */
|
||||
--accent: 0 144 255; /* blue-9 #0090ff */
|
||||
--accent-hover: 5 136 240; /* blue-10 #0588f0 */
|
||||
--fg-primary: 28 32 36; /* slate-12 #1c2024 */
|
||||
--fg-secondary: 96 100 108; /* slate-11 #60646c */
|
||||
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */
|
||||
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
|
||||
/* Status: green/red/amber step 11 (light) — the same "readable text" step
|
||||
as fg-secondary, so success/danger/warning sit at the same visual weight
|
||||
as ordinary secondary text, not shouting over it. */
|
||||
--status-success: 33 131 88; /* green-11 #218358 */
|
||||
--status-danger: 206 44 49; /* red-11 #ce2c31 */
|
||||
--status-warning: 171 100 0; /* amber-11 #ab6400 */
|
||||
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
|
||||
page (the Settings appearance cards).
|
||||
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
|
||||
as it does in dark: the page is the DARKEST step and a card is brighter
|
||||
than the page it sits on. The previous light block used the scale in its
|
||||
native direction, which inverted the whole language — a near-white page
|
||||
(glare) under grey cards that read as recessed, and every `hover:bg-*`
|
||||
darkening instead of lifting.
|
||||
|
||||
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
|
||||
A cool near-white ramp kept reading as glare no matter how far the page
|
||||
step was pulled down, because the brightest thing on screen was also the
|
||||
bluest. Cream lowers the perceived glare at the same measured lightness
|
||||
and gives every neutral a warm cast to sit against. The whole ramp sits
|
||||
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
|
||||
white: at full brightness a large light panel still tires the eye. */
|
||||
--surface-0: 232 229 218; /* #e8e5da — page background */
|
||||
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
|
||||
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
|
||||
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
|
||||
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
|
||||
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
|
||||
--border: 207 201 184; /* #cfc9b8 */
|
||||
--border-light: 222 217 204; /* #ded9cc */
|
||||
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
|
||||
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
|
||||
fails as a link or a button label. This is 5.3:1 as text on a card and
|
||||
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
|
||||
onto a near-black page is a different problem with a different answer. */
|
||||
--accent: 163 74 40; /* #a34a28 */
|
||||
--accent-hover: 142 63 32; /* #8e3f20 */
|
||||
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
|
||||
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
|
||||
/* Measured, not eyeballed: this carries real content (timestamps, counts),
|
||||
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
|
||||
sits a clear step below fg-secondary. */
|
||||
--fg-muted: 104 99 89; /* #686359 */
|
||||
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
|
||||
over cream. The SHADOW carries "raised" here, not the quiet border. */
|
||||
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
|
||||
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
|
||||
the card; these also clear it against the status's OWN densest chip (a
|
||||
20% tint of itself — 4.5:1), which is where the label actually sits. */
|
||||
--status-success: 22 104 66; /* #166842 */
|
||||
--status-danger: 170 36 41; /* #aa2429 */
|
||||
--status-warning: 133 79 0; /* #854f00 */
|
||||
/* What every status/accent FILL is pre-mixed against (see `tint()` in
|
||||
tailwind.config.js) — the card surface, because that is where chips and
|
||||
alert strips overwhelmingly sit. */
|
||||
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
|
||||
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
|
||||
--tint-base: var(--surface-3);
|
||||
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
|
||||
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
|
||||
on a near-black code block and invisible on a light one. */
|
||||
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-string: 4 120 87; /* emerald-700 #047857 */
|
||||
--syn-number: 194 65 12; /* orange-700 #c2410c */
|
||||
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
|
||||
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
|
||||
--syn-function: 180 83 9; /* amber-700 #b45309 */
|
||||
--syn-operator: 190 24 93; /* pink-700 #be185d */
|
||||
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
|
||||
--syn-tag: 190 18 60; /* rose-700 #be123c */
|
||||
--syn-plain: var(--fg-primary);
|
||||
--syn-add: 4 120 87; /* emerald-700 */
|
||||
--syn-del: 185 28 28; /* red-700 #b91c1c */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -70,6 +121,187 @@
|
||||
--status-success: 61 214 140; /* green-11 dark #3dd68c */
|
||||
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
|
||||
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
|
||||
--tint-base: var(--surface-3);
|
||||
/* Unchanged from the pastels this file has always drawn code in. */
|
||||
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
|
||||
--syn-number: 253 186 116; /* orange-300 #fdba74 */
|
||||
--syn-keyword: 196 181 253; /* violet-300 #c4b5fd */
|
||||
--syn-builtin: 125 211 252; /* sky-300 #7dd3fc */
|
||||
--syn-function: 254 240 138; /* yellow-200 #fef08a */
|
||||
--syn-operator: 249 168 212; /* pink-300 #f9a8d4 */
|
||||
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
|
||||
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
|
||||
--syn-tag: 253 164 175; /* rose-300 #fda4af */
|
||||
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
|
||||
--syn-add: 110 231 183; /* emerald-300 */
|
||||
--syn-del: 252 165 165; /* red-300 #fca5a5 */
|
||||
}
|
||||
|
||||
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
|
||||
* Settings → Appearance). Each overrides only what differs from its mode's
|
||||
* default block above; `--tint-base` and light `--syn-plain` are derived there.
|
||||
* Dark ones pair with `.dark`, light ones with `[data-mode="light"]`, so a
|
||||
* theme can never paint over the wrong mode whatever order the attributes
|
||||
* land in. Contrast is held to the defaults' own bar: fg-muted ≥4.5:1 on a
|
||||
* light card, status labels ≥4.5:1 on their own /20 chip in light themes. */
|
||||
.dark[data-theme="darcula"] {
|
||||
--surface-0: 30 31 32; /* #1e1f20 */
|
||||
--surface-1: 35 36 37; /* #232425 */
|
||||
--surface-2: 39 41 42; /* #27292a */
|
||||
--surface-3: 43 43 43; /* #2b2b2b */
|
||||
--surface-4: 51 53 55; /* #333537 */
|
||||
--surface-5: 60 63 65; /* #3c3f41 */
|
||||
--border: 63 66 68; /* #3f4244 */
|
||||
--border-light: 77 81 83; /* #4d5153 */
|
||||
--accent: 53 146 196; /* #3592c4 */
|
||||
--accent-hover: 74 163 214; /* #4aa3d6 */
|
||||
--fg-primary: 220 220 220; /* #dcdcdc */
|
||||
--fg-secondary: 169 183 198; /* #a9b7c6 */
|
||||
--fg-muted: 140 146 150; /* #8c9296 */
|
||||
--status-success: 126 194 135; /* #7ec287 */
|
||||
--status-danger: 255 143 140; /* #ff8f8c */
|
||||
--status-warning: 232 191 106; /* #e8bf6a */
|
||||
--syn-comment: 128 128 128; /* #808080 */
|
||||
--syn-string: 127 159 105; /* #7f9f69 */
|
||||
--syn-number: 104 151 187; /* #6897bb */
|
||||
--syn-keyword: 204 120 50; /* #cc7832 */
|
||||
--syn-builtin: 136 136 198; /* #8888c6 */
|
||||
--syn-function: 255 198 109; /* #ffc66d */
|
||||
--syn-operator: 169 183 198; /* #a9b7c6 */
|
||||
--syn-punctuation: 169 183 198; /* #a9b7c6 */
|
||||
--syn-property: 168 139 192; /* #a88bc0 */
|
||||
--syn-tag: 232 191 106; /* #e8bf6a */
|
||||
--syn-add: 106 171 115; /* #6aab73 */
|
||||
--syn-del: 255 107 104; /* #ff6b68 */
|
||||
--syn-plain: 169 183 198; /* #a9b7c6 */
|
||||
}
|
||||
|
||||
.dark[data-theme="apple-dark"] {
|
||||
--surface-0: 22 22 24; /* #161618 */
|
||||
--surface-1: 28 28 30; /* #1c1c1e */
|
||||
--surface-2: 36 36 38; /* #242426 */
|
||||
--surface-3: 44 44 46; /* #2c2c2e */
|
||||
--surface-4: 54 54 56; /* #363638 */
|
||||
--surface-5: 58 58 60; /* #3a3a3c */
|
||||
--border: 56 56 58; /* #38383a */
|
||||
--border-light: 72 72 74; /* #48484a */
|
||||
--accent: 10 132 255; /* #0a84ff */
|
||||
--accent-hover: 64 156 255; /* #409cff */
|
||||
--fg-primary: 245 245 247; /* #f5f5f7 */
|
||||
--fg-secondary: 174 174 178; /* #aeaeb2 */
|
||||
--fg-muted: 142 142 147; /* #8e8e93 */
|
||||
--status-success: 48 209 88; /* #30d158 */
|
||||
--status-danger: 255 140 134; /* #ff8c86 */
|
||||
--status-warning: 255 214 10; /* #ffd60a */
|
||||
--syn-comment: 127 140 152; /* #7f8c98 */
|
||||
--syn-string: 252 106 93; /* #fc6a5d */
|
||||
--syn-number: 208 191 105; /* #d0bf69 */
|
||||
--syn-keyword: 252 95 163; /* #fc5fa3 */
|
||||
--syn-builtin: 93 216 255; /* #5dd8ff */
|
||||
--syn-function: 103 183 164; /* #67b7a4 */
|
||||
--syn-operator: 161 161 166; /* #a1a1a6 */
|
||||
--syn-punctuation: 161 161 166; /* #a1a1a6 */
|
||||
--syn-property: 181 140 240; /* #b58cf0 */
|
||||
--syn-tag: 252 95 163; /* #fc5fa3 */
|
||||
--syn-add: 48 209 88; /* #30d158 */
|
||||
--syn-del: 255 105 97; /* #ff6961 */
|
||||
--syn-plain: 229 229 234; /* #e5e5ea */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="apple-light"] {
|
||||
--surface-0: 233 233 238; /* #e9e9ee */
|
||||
--surface-1: 239 239 244; /* #efeff4 */
|
||||
--surface-2: 245 245 248; /* #f5f5f8 */
|
||||
--surface-3: 251 251 253; /* #fbfbfd */
|
||||
--surface-4: 253 253 254; /* #fdfdfe */
|
||||
--surface-5: 255 255 255; /* #ffffff */
|
||||
--border: 209 209 214; /* #d1d1d6 */
|
||||
--border-light: 229 229 234; /* #e5e5ea */
|
||||
--accent: 0 102 204; /* #0066cc */
|
||||
--accent-hover: 0 85 179; /* #0055b3 */
|
||||
--fg-primary: 29 29 31; /* #1d1d1f */
|
||||
--fg-secondary: 81 81 84; /* #515154 */
|
||||
--fg-muted: 110 110 115; /* #6e6e73 */
|
||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
|
||||
--status-success: 23 107 46; /* #176b2e */
|
||||
--status-danger: 179 0 14; /* #b3000e */
|
||||
--status-warning: 122 79 0; /* #7a4f00 */
|
||||
--syn-comment: 93 108 121; /* #5d6c79 */
|
||||
--syn-string: 196 26 22; /* #c41a16 */
|
||||
--syn-number: 28 0 207; /* #1c00cf */
|
||||
--syn-keyword: 155 35 147; /* #9b2393 */
|
||||
--syn-builtin: 11 79 121; /* #0b4f79 */
|
||||
--syn-function: 50 109 116; /* #326d74 */
|
||||
--syn-operator: 75 75 80; /* #4b4b50 */
|
||||
--syn-punctuation: 75 75 80; /* #4b4b50 */
|
||||
--syn-property: 57 0 160; /* #3900a0 */
|
||||
--syn-tag: 155 35 147; /* #9b2393 */
|
||||
--syn-add: 31 122 54; /* #1f7a36 */
|
||||
--syn-del: 197 0 15; /* #c5000f */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="intellij-light"] {
|
||||
--surface-0: 236 238 242; /* #eceef2 */
|
||||
--surface-1: 241 242 245; /* #f1f2f5 */
|
||||
--surface-2: 247 248 250; /* #f7f8fa */
|
||||
--surface-3: 253 253 254; /* #fdfdfe */
|
||||
--surface-4: 255 255 255; /* #ffffff */
|
||||
--surface-5: 255 255 255; /* #ffffff */
|
||||
--border: 211 213 219; /* #d3d5db */
|
||||
--border-light: 228 230 235; /* #e4e6eb */
|
||||
--accent: 47 106 227; /* #2f6ae3 */
|
||||
--accent-hover: 39 88 192; /* #2758c0 */
|
||||
--fg-primary: 30 31 34; /* #1e1f22 */
|
||||
--fg-secondary: 73 75 87; /* #494b57 */
|
||||
--fg-muted: 108 112 126; /* #6c707e */
|
||||
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
|
||||
--status-success: 23 106 45; /* #176a2d */
|
||||
--status-danger: 173 28 38; /* #ad1c26 */
|
||||
--status-warning: 124 82 0; /* #7c5200 */
|
||||
--syn-comment: 112 112 112; /* #707070 */
|
||||
--syn-string: 6 125 23; /* #067d17 */
|
||||
--syn-number: 23 80 235; /* #1750eb */
|
||||
--syn-keyword: 0 51 179; /* #0033b3 */
|
||||
--syn-builtin: 0 0 128; /* #000080 */
|
||||
--syn-function: 0 98 122; /* #00627a */
|
||||
--syn-operator: 75 75 75; /* #4b4b4b */
|
||||
--syn-punctuation: 75 75 75; /* #4b4b4b */
|
||||
--syn-property: 135 16 148; /* #871094 */
|
||||
--syn-tag: 0 51 179; /* #0033b3 */
|
||||
--syn-add: 6 125 23; /* #067d17 */
|
||||
--syn-del: 192 31 42; /* #c01f2a */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="canvas"] {
|
||||
--surface-0: 227 220 205; /* #e3dccd */
|
||||
--surface-1: 232 226 212; /* #e8e2d4 */
|
||||
--surface-2: 237 232 220; /* #ede8dc */
|
||||
--surface-3: 243 239 230; /* #f3efe6 */
|
||||
--surface-4: 247 244 237; /* #f7f4ed */
|
||||
--surface-5: 250 248 243; /* #faf8f3 */
|
||||
--border: 203 191 166; /* #cbbfa6 */
|
||||
--border-light: 220 211 192; /* #dcd3c0 */
|
||||
--accent: 122 82 48; /* #7a5230 */
|
||||
--accent-hover: 102 63 34; /* #663f22 */
|
||||
--fg-primary: 43 38 32; /* #2b2620 */
|
||||
--fg-secondary: 94 85 74; /* #5e554a */
|
||||
--fg-muted: 107 97 85; /* #6b6155 */
|
||||
--status-success: 54 93 36; /* #365d24 */
|
||||
--status-danger: 147 41 31; /* #93291f */
|
||||
--status-warning: 109 76 0; /* #6d4c00 */
|
||||
--syn-comment: 125 114 98; /* #7d7262 */
|
||||
--syn-string: 77 107 31; /* #4d6b1f */
|
||||
--syn-number: 154 91 0; /* #9a5b00 */
|
||||
--syn-keyword: 138 59 18; /* #8a3b12 */
|
||||
--syn-builtin: 47 94 110; /* #2f5e6e */
|
||||
--syn-function: 122 90 0; /* #7a5a00 */
|
||||
--syn-operator: 138 48 80; /* #8a3050 */
|
||||
--syn-punctuation: 107 97 85; /* #6b6155 */
|
||||
--syn-property: 59 102 96; /* #3b6660 */
|
||||
--syn-tag: 142 47 47; /* #8e2f2f */
|
||||
--syn-add: 77 107 31; /* #4d6b1f */
|
||||
--syn-del: 159 47 35; /* #9f2f23 */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -102,12 +334,17 @@
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(0.8);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
/* The browser draws this glyph near-black. Inverting it is a DARK-mode fix;
|
||||
applied globally it painted a white icon onto a white field. */
|
||||
.dark ::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(0.8);
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file laneOrderStorage.test.ts
|
||||
* @description Tests for the Workspace lane strip's stored card order: the
|
||||
* storage round-trip, and the two rules that keep a stale stored order from
|
||||
* losing a lane — unknown ids land at the end, vanished ids are skipped.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
|
||||
|
||||
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
|
||||
|
||||
describe("laneOrderStorage", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("returns an empty order when nothing is stored", () => {
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips a written order", () => {
|
||||
writeLaneOrder([3, 1, 2]);
|
||||
expect(readLaneOrder()).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("falls back to an empty order when stored JSON is malformed", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", "{not json");
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops non-numeric entries from a stored order", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([1, "2", null, 3]));
|
||||
expect(readLaneOrder()).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortByStoredOrder", () => {
|
||||
it("keeps server order when nothing is stored", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [])).toEqual(lanes(1, 2, 3));
|
||||
});
|
||||
|
||||
it("applies the stored order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [3, 1, 2])).toEqual(lanes(3, 1, 2));
|
||||
});
|
||||
|
||||
it("appends lanes the stored order has never seen, in server order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3, 4), [3, 1])).toEqual(lanes(3, 1, 2, 4));
|
||||
});
|
||||
|
||||
it("skips stored ids whose lane no longer exists", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 3), [3, 2, 1])).toEqual(lanes(3, 1));
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const input = lanes(1, 2, 3);
|
||||
sortByStoredOrder(input, [3, 2, 1]);
|
||||
expect(input.map((l) => l.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveLane", () => {
|
||||
it("moves a card forward", () => {
|
||||
expect(moveLane([1, 2, 3], 0, 2)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it("moves a card backward", () => {
|
||||
expect(moveLane([1, 2, 3], 2, 0)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns the order unchanged for a no-op or out-of-range move", () => {
|
||||
expect(moveLane([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file terminalTheme.test.ts
|
||||
* @description Tests for the xterm theme builder: CSS-variable RGB triplets
|
||||
* become hex, status/accent map to the ANSI hues, bright twins are lighter,
|
||||
* and the probe resolves a named dark theme's variables.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildTerminalTheme, terminalThemeFor } from "../terminalTheme";
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
"--surface-0": " 17 17 19",
|
||||
"--surface-3": "39 42 45",
|
||||
"--fg-primary": "237 238 240",
|
||||
"--fg-secondary": "176 180 186",
|
||||
"--fg-muted": "105 110 119",
|
||||
"--border-light": "67 72 78",
|
||||
"--accent": "0 144 255",
|
||||
"--status-success": "61 214 140",
|
||||
"--status-danger": "255 149 146",
|
||||
"--status-warning": "255 202 22",
|
||||
};
|
||||
|
||||
describe("buildTerminalTheme", () => {
|
||||
it("maps theme tokens onto the xterm palette", () => {
|
||||
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
||||
expect(t.background).toBe("#111113");
|
||||
expect(t.foreground).toBe("#edeef0");
|
||||
expect(t.cursor).toBe("#edeef0");
|
||||
expect(t.selectionBackground).toBe("#43484e");
|
||||
expect(t.red).toBe("#ff9592");
|
||||
expect(t.green).toBe("#3dd68c");
|
||||
expect(t.yellow).toBe("#ffca16");
|
||||
expect(t.blue).toBe("#0090ff");
|
||||
expect(t.black).toBe("#43484e");
|
||||
expect(t.brightBlack).toBe("#696e77");
|
||||
expect(t.brightWhite).toBe("#edeef0");
|
||||
});
|
||||
|
||||
it("makes each bright twin lighter than its base colour", () => {
|
||||
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
||||
const sum = (hex: string) =>
|
||||
[1, 3, 5].reduce((a, i) => a + parseInt(hex.slice(i, i + 2), 16), 0);
|
||||
for (const c of ["red", "green", "yellow", "blue", "magenta", "cyan"] as const) {
|
||||
const base = t[c]!;
|
||||
const bright = t[`bright${c[0]!.toUpperCase()}${c.slice(1)}` as keyof typeof t] as string;
|
||||
expect(sum(bright)).toBeGreaterThan(sum(base));
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a colour unset when its variable is missing, so xterm keeps its default", () => {
|
||||
const t = buildTerminalTheme(() => "");
|
||||
expect(t.background).toBeUndefined();
|
||||
expect(t.red).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminalThemeFor", () => {
|
||||
it("resolves variables on a dark probe scoped to the theme id, then removes it", () => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `.dark { --surface-0: 1 2 3; } .dark[data-theme="darcula"] { --surface-0: 30 31 32; }`;
|
||||
document.head.appendChild(style);
|
||||
try {
|
||||
expect(terminalThemeFor("default").background).toBe("#010203");
|
||||
expect(terminalThemeFor("darcula").background).toBe("#1e1f20");
|
||||
expect(document.body.children).toHaveLength(0);
|
||||
} finally {
|
||||
style.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
+378
-20
@@ -396,10 +396,16 @@ import type {
|
||||
CostResult,
|
||||
DashboardEvent,
|
||||
Lane,
|
||||
LaneChangeRange,
|
||||
LaneChanges,
|
||||
LaneCommit,
|
||||
LaneFileContent,
|
||||
LaneTreeEntry,
|
||||
LaneCounts,
|
||||
LaneGitFacts,
|
||||
LaneRuntime,
|
||||
LaneTool,
|
||||
LaneWorktree,
|
||||
ModelPricing,
|
||||
NamedLock,
|
||||
PipelineStats,
|
||||
@@ -476,6 +482,15 @@ export function dashboardToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
|
||||
function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (scope) qs.set("scope", scope);
|
||||
if (cwd) qs.set("cwd", cwd);
|
||||
const q = qs.toString();
|
||||
return q ? `?${q}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared fetch wrapper used by every method on {@link api}. Prefixes `path`
|
||||
* with {@link BASE} ("/api"), attaches the dashboard auth token (if any) as
|
||||
@@ -500,7 +515,11 @@ export function dashboardToken(): string | null {
|
||||
* @returns The parsed JSON response body, typed as `T`.
|
||||
* @throws {Error} When the response status is not ok (non-2xx). The thrown
|
||||
* message is `body.error.message` if the error body parsed as JSON and carried
|
||||
* one, otherwise the literal `HTTP <status>`.
|
||||
* one, otherwise the literal `HTTP <status>`. The server's own `error.code`
|
||||
* (e.g. `"ERUNLIVE"`) rides along as `.code` on the thrown error — every
|
||||
* route's error body carries one (see `server/routes/*.js`), and a caller
|
||||
* that needs to branch on which error this was should check `.code`, never
|
||||
* guess at substrings of the human-readable `.message`.
|
||||
*/
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = dashboardToken();
|
||||
@@ -517,7 +536,10 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
|
||||
// chain below cleanly degrades to the generic `HTTP <status>` message.
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error?.message || `HTTP ${res.status}`);
|
||||
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||
code: body?.error?.code,
|
||||
status: res.status,
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -944,7 +966,14 @@ export const api = {
|
||||
node_version: string;
|
||||
platform: string;
|
||||
ws_connections: number;
|
||||
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
|
||||
memory: {
|
||||
rss: number;
|
||||
heapTotal: number;
|
||||
heapUsed: number;
|
||||
external: number;
|
||||
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
|
||||
heapLimit?: number;
|
||||
};
|
||||
cpu_load: number[];
|
||||
arch: string;
|
||||
total_mem: number;
|
||||
@@ -1370,10 +1399,11 @@ export const api = {
|
||||
* list endpoints below (`agents`, `commands`, `outputStyles`).
|
||||
*
|
||||
* @param scope Optional {@link CcScope} ("user"|"project"|"all") filter.
|
||||
* @param cwd Project directory to read, instead of the server's own cwd.
|
||||
* @returns `{ items }` — the {@link CcMdItem} summaries for each skill.
|
||||
*/
|
||||
skills: (scope?: CcScope) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/skills${scope ? `?scope=${scope}` : ""}`),
|
||||
skills: (scope?: CcScope, cwd?: string) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/skills${ccQuery(scope, cwd)}`),
|
||||
/**
|
||||
* GET /api/cc-config/agents - user and/or project subagent definitions.
|
||||
* @param scope Optional {@link CcScope} filter.
|
||||
@@ -1384,10 +1414,11 @@ export const api = {
|
||||
/**
|
||||
* GET /api/cc-config/commands - user and/or project slash commands.
|
||||
* @param scope Optional {@link CcScope} filter.
|
||||
* @param cwd Project directory to read, instead of the server's own cwd.
|
||||
* @returns `{ items }` — {@link CcMdItem} summaries for each command.
|
||||
*/
|
||||
commands: (scope?: CcScope) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/commands${scope ? `?scope=${scope}` : ""}`),
|
||||
commands: (scope?: CcScope, cwd?: string) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/commands${ccQuery(scope, cwd)}`),
|
||||
/**
|
||||
* GET /api/cc-config/output-styles.
|
||||
* @param scope Optional {@link CcScope} filter.
|
||||
@@ -1584,6 +1615,21 @@ export const api = {
|
||||
"/cc-config/profiles/activate",
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
/**
|
||||
* POST /api/cc-config/profiles/usage - how much of this profile's account
|
||||
* quota is already spent.
|
||||
*
|
||||
* A POST because answering it is a write: a saved profile's access token has
|
||||
* almost always expired, so the server refreshes it against Anthropic first
|
||||
* and the refresh rotates the credentials stored on disk. That is also why
|
||||
* this is never called on a timer — only when the user asks for one profile.
|
||||
* No token material crosses the boundary in either direction.
|
||||
*/
|
||||
profileUsage: (name: string) =>
|
||||
request<CcProfileUsage>("/cc-config/profiles/usage", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
|
||||
deleteProfile: (name: string) =>
|
||||
request<{ ok: true; name: string }>("/cc-config/profiles", {
|
||||
@@ -1868,6 +1914,49 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── AI Router API ─────────────────────────────
|
||||
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* lane can be pointed at instead of the vendor's own API. Managed in the
|
||||
* **AI Router** tab of Settings; attached to a lane when the lane is created.
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
/** POST /api/ai-routers - save a new profile. */
|
||||
create: (body: AiRouterInput) =>
|
||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
|
||||
* keeps the stored one, which is what the edit form always does: it cannot
|
||||
* display a key, so it has none to send back.
|
||||
*/
|
||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
||||
request<AiRouter & { lanes: AiRouterLaneSync }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
|
||||
* before a profile that cannot reach anything is saved onto a lane.
|
||||
*/
|
||||
test: (body: AiRouterTestInput) =>
|
||||
request<AiRouterTestResult>("/ai-routers/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/ai-routers/:name - also resets every lane using it back to
|
||||
* native, on disk as well as in the DB.
|
||||
*/
|
||||
remove: (name: string) =>
|
||||
request<{ removed: string; lanes: AiRouterLaneSync }>(
|
||||
`/ai-routers/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
|
||||
// ────────────────────────────────── Run API ─────────────────────────────────
|
||||
/** Spawn/manage headless or conversational `claude` CLI child processes
|
||||
* launched from the dashboard's Run page, and stream their output. */
|
||||
@@ -2219,7 +2308,14 @@ export const api = {
|
||||
* location isn't tied to a specific project (e.g. Sagi's mascot lane).
|
||||
* @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created.
|
||||
*/
|
||||
ensure: (body: { cwd?: string; title?: string; pipeline?: string; tool?: LaneTool }) =>
|
||||
ensure: (body: {
|
||||
cwd?: string;
|
||||
title?: string;
|
||||
pipeline?: string;
|
||||
tool?: LaneTool;
|
||||
/** Only a lane on THIS directory counts, not one on a parent of it. */
|
||||
exact?: boolean;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
@@ -2246,11 +2342,13 @@ export const api = {
|
||||
/**
|
||||
* GET /api/lanes/branches — a candidate source repo's local branches.
|
||||
* @param repo Absolute path to an existing git repository.
|
||||
* @returns `{ branches, current }` — local branch names (not `origin/*`)
|
||||
* and which one is currently checked out (null if detached).
|
||||
* @returns `{ branches, current, worktrees }` — local branch names (not
|
||||
* `origin/*`), which one is currently checked out (null if detached),
|
||||
* and the repo's existing worktrees, so the picker can grey out the
|
||||
* branches git would refuse to check out a second time.
|
||||
*/
|
||||
branches: (repo: string) =>
|
||||
request<{ branches: string[]; current: string | null }>(
|
||||
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
|
||||
`/lanes/branches?repo=${encodeURIComponent(repo)}`
|
||||
),
|
||||
/**
|
||||
@@ -2299,6 +2397,154 @@ export const api = {
|
||||
* readable git repository, which is a normal state rather than an error.
|
||||
*/
|
||||
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
|
||||
/**
|
||||
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
|
||||
* the Changes panel asks for.
|
||||
* @param id The lane id.
|
||||
* @param opts `range` picks the comparison (`work` = uncommitted vs HEAD,
|
||||
* `branch` = base...HEAD). Pass `path` for one file's hunks, or
|
||||
* `full: true` for every file's; with neither it is a summary.
|
||||
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
|
||||
*/
|
||||
changes: (
|
||||
id: number,
|
||||
opts: {
|
||||
range?: LaneChangeRange;
|
||||
path?: string;
|
||||
full?: boolean;
|
||||
sha?: string;
|
||||
context?: number;
|
||||
} = {}
|
||||
) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.range) q.set("range", opts.range);
|
||||
if (opts.path) q.set("path", opts.path);
|
||||
if (opts.full) q.set("full", "1");
|
||||
if (opts.sha) q.set("sha", opts.sha);
|
||||
if (opts.context != null) q.set("context", String(opts.context));
|
||||
return request<LaneChanges>(`/lanes/${id}/git/changes?${q.toString()}`);
|
||||
},
|
||||
/**
|
||||
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
|
||||
* @param id The lane id.
|
||||
* @param opts `file` narrows the list to the commits touching one path.
|
||||
*/
|
||||
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.limit) q.set("limit", String(opts.limit));
|
||||
if (opts.file) q.set("file", opts.file);
|
||||
return request<{ available: boolean; commits: LaneCommit[] }>(
|
||||
`/lanes/${id}/git/log?${q.toString()}`
|
||||
);
|
||||
},
|
||||
/**
|
||||
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
|
||||
* @param id The lane id.
|
||||
* @param path Repo-relative path.
|
||||
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
|
||||
*/
|
||||
gitFile: (id: number, path: string, rev = "work") =>
|
||||
request<LaneFileContent>(
|
||||
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
|
||||
),
|
||||
/**
|
||||
* PUT /api/lanes/:id/git/file — save the editor's text over the working
|
||||
* file. `baseSha` is the `sha` it was opened at; a file that changed on
|
||||
* disk since answers 409 ESTALE rather than being overwritten.
|
||||
*/
|
||||
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
|
||||
request<LaneFileContent>(`/lanes/${id}/git/file`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ path, content, baseSha }),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
|
||||
* quick-open. Ignored files never appear (it reads `git ls-files`).
|
||||
* @param id The lane id.
|
||||
* @param q The substring typed into the quick-open box.
|
||||
*/
|
||||
gitLs: (id: number, q: string) =>
|
||||
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
|
||||
`/lanes/${id}/git/ls?q=${encodeURIComponent(q)}`
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
|
||||
* @param id The lane id.
|
||||
* @param path Repo-relative directory; "" for the repo root.
|
||||
*/
|
||||
/**
|
||||
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
|
||||
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
|
||||
*/
|
||||
gitTreeAll: (id: number) =>
|
||||
request<{
|
||||
available: boolean;
|
||||
levels: Record<string, LaneTreeEntry[]>;
|
||||
truncated: boolean;
|
||||
}>(`/lanes/${id}/git/tree-all`),
|
||||
gitTree: (id: number, path = "") =>
|
||||
request<{
|
||||
available: boolean;
|
||||
path: string;
|
||||
entries: LaneTreeEntry[];
|
||||
truncated: boolean;
|
||||
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`),
|
||||
/**
|
||||
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
|
||||
* the branch switcher. The repo comes from the lane row, never the query.
|
||||
* @param id The lane id.
|
||||
*/
|
||||
gitBranches: (id: number) =>
|
||||
request<{
|
||||
available: boolean;
|
||||
branches: string[];
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(`/lanes/${id}/git/branches`),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
|
||||
* `branch-delete`.
|
||||
* @param body `{message, only}` for commit; `{branch, create}` for switch;
|
||||
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
|
||||
* `{paths, confirm: true}` for discard — which is irreversible, so the
|
||||
* server refuses it without that flag.
|
||||
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
|
||||
*/
|
||||
gitAction: (
|
||||
id: number,
|
||||
action:
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "commit"
|
||||
| "switch"
|
||||
| "stage"
|
||||
| "unstage"
|
||||
| "discard"
|
||||
| "stage-hunk"
|
||||
| "discard-hunk"
|
||||
| "branch-create"
|
||||
| "branch-delete",
|
||||
body: {
|
||||
message?: string;
|
||||
only?: "staged";
|
||||
branch?: string;
|
||||
base?: string;
|
||||
create?: boolean;
|
||||
paths?: string[];
|
||||
path?: string;
|
||||
hunk?: string;
|
||||
context?: number;
|
||||
confirm?: boolean;
|
||||
} = {}
|
||||
) =>
|
||||
request<{ action: string; ok: true; output: string; branch?: string }>(
|
||||
`/lanes/${id}/git/${action}`,
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
|
||||
* ports, service liveness, and the last boot error.
|
||||
@@ -2375,7 +2621,9 @@ export const api = {
|
||||
* @returns `{ lane, ok }` — the updated lane (if applicable) or a boolean result.
|
||||
*/
|
||||
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
||||
request<{ lane?: Lane; ok?: boolean }>(`/lanes/${id}/${action}`, {
|
||||
// `run_id` answers a `start`: the session it opened, which for a split
|
||||
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
|
||||
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -2434,6 +2682,23 @@ export const api = {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/**
|
||||
* POST /lanes/:id/router/apply - point this lane at an AI Router profile,
|
||||
* or back at `native`. Writes the lane's own config on disk; only the
|
||||
* profile NAME comes from here.
|
||||
*/
|
||||
routerApply: (id: number, profile: string) =>
|
||||
request<{
|
||||
tool: string;
|
||||
file: string | null;
|
||||
profile: string;
|
||||
/** A pane is live and keeps the old models until it restarts. */
|
||||
restart_required: boolean;
|
||||
run_id: string | null;
|
||||
}>(`/lanes/${id}/router/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profile }),
|
||||
}),
|
||||
mcpSync: (id: number) =>
|
||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
|
||||
method: "POST",
|
||||
@@ -2444,8 +2709,6 @@ export const api = {
|
||||
`/lanes/${id}/profile/init`,
|
||||
{ method: "POST", body: JSON.stringify({ force }) }
|
||||
),
|
||||
integration: (id: number, name: string) =>
|
||||
request<{ enabled: boolean }>(`/lanes/${id}/integrations/${encodeURIComponent(name)}`),
|
||||
syncBaseCheck: (id: number, branch?: string) =>
|
||||
request<{
|
||||
code: number;
|
||||
@@ -2605,10 +2868,96 @@ export interface CcProfile extends CcAccountIdentity {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** One quota window as POST /api/cc-config/profiles/usage reports it. */
|
||||
export interface CcUsageWindow {
|
||||
/** Percent of the window's allowance already spent, 0-100. */
|
||||
utilization: number | null;
|
||||
/** ISO timestamp the window rolls over at, when the account reports one. */
|
||||
resetsAt: string | null;
|
||||
}
|
||||
|
||||
/** An account's spent quota, for the profile switcher. Percentages and reset
|
||||
* times only — the OAuth token stays on the server. */
|
||||
export interface CcProfileUsage {
|
||||
name: string;
|
||||
/** True when the stored token had expired and was refreshed to answer this. */
|
||||
refreshed: boolean;
|
||||
fiveHour: CcUsageWindow | null;
|
||||
sevenDay: CcUsageWindow | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
||||
* var Claude Code reads; a router that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
export interface AiRouterModels {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
smallFast: string;
|
||||
subagent: string;
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** Empty string for `native`, an http(s) URL otherwise. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
|
||||
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
|
||||
* its HTTP code (null when nothing answered at all), and `message` is its own
|
||||
* words — truncated server-side, never the key. */
|
||||
export interface AiRouterTestResult {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
|
||||
* key for a profile being edited — but only while `baseUrl` still matches the
|
||||
* saved one, so a typed-in endpoint always needs a typed-in key. */
|
||||
export interface AiRouterTestInput {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** How an edit or a delete landed on the lanes already using that profile.
|
||||
* Attaching copies a profile, so the store alone is not the whole truth: a
|
||||
* lane listed in `failed` still carries the OLD key and endpoint. */
|
||||
export interface AiRouterLaneSync {
|
||||
updated: number;
|
||||
/** One `"<id> (<title>): <code>"` line per lane that could not be rewritten. */
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/** Body accepted when creating or updating a profile. */
|
||||
export interface AiRouterInput {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
/** Omit on update to keep the stored key. */
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
* as summarized by the `ccConfig` list endpoints. The list endpoints return
|
||||
* a lightweight summary (frontmatter + a preview) rather than full contents;
|
||||
@@ -3231,7 +3580,7 @@ export interface CodexMarketplaceActionArgs {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
|
||||
export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
|
||||
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
|
||||
@@ -3267,6 +3616,8 @@ export interface RunStartArgs {
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
/** Which Workspace split pane's session this is: 0 is the lane's own. */
|
||||
slot?: number | null;
|
||||
status: RunStatus;
|
||||
cwd: string | null;
|
||||
model: string | null;
|
||||
@@ -3317,7 +3668,7 @@ export interface DashboardRunHistoryItem {
|
||||
export interface CwdSuggestion {
|
||||
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
|
||||
* = previously used for a run. */
|
||||
kind: "dashboard" | "home" | "recent";
|
||||
kind: "dashboard" | "home" | "recent" | "repo";
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -3356,10 +3707,17 @@ export const RUN_EFFORT_CHOICES: EffortChoice[] = [
|
||||
// configured default and the rest map to concrete `--model` values.
|
||||
export const RUN_MODEL_CHOICES: ModelChoice[] = [
|
||||
{ id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" },
|
||||
{ id: "claude-opus-5", label: "Opus 5", hint: "Highest capability" },
|
||||
{ id: "claude-sonnet-5", label: "Sonnet 5", hint: "Balanced capability and speed" },
|
||||
{ id: "claude-fable-5", label: "Fable 5", hint: "Fast output, same capability tier as Opus" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", hint: "Fastest, lightest" },
|
||||
// Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/
|
||||
// `sonnet`/`haiku` as "an alias for the latest model", and the CLI resolves
|
||||
// each one itself (2.1.278 maps fable -> claude-fable-5-1). A pinned id here
|
||||
// is a list that silently goes stale every release and has to be edited by
|
||||
// hand; an alias follows whatever `claude` was last updated to. Cost math is
|
||||
// unaffected — pricing keys off the model the transcript reports, which is
|
||||
// the resolved full id.
|
||||
{ id: "fable", label: "Fable (latest)", hint: "Fast output, same capability tier as Opus" },
|
||||
{ id: "opus", label: "Opus (latest)", hint: "Highest capability" },
|
||||
{ id: "sonnet", label: "Sonnet (latest)", hint: "Balanced capability and speed" },
|
||||
{ id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" },
|
||||
];
|
||||
|
||||
// Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent
|
||||
|
||||
+22
-19
@@ -1033,46 +1033,49 @@ export function highlight(source: string, lang: string): Token[] {
|
||||
/**
|
||||
* Map a token type to a Tailwind colour class for rendering.
|
||||
* @param type A {@link TokenType} produced by {@link highlight}.
|
||||
* @returns Tailwind utility classes (text colour, and a background tint for
|
||||
* diff add/remove lines); falls back to a neutral gray for "plain"/unknown.
|
||||
* @returns Tailwind utility classes built on the `syntax.*` THEME TOKENS
|
||||
* (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
|
||||
* pastel one this file has always used, and light mode takes the same hues
|
||||
* four steps darker, because a pastel keyword on a light code block is
|
||||
* unreadable. Diff rows take a /20 status tint, pre-mixed per theme.
|
||||
*/
|
||||
export function tokenClass(type: TokenType): string {
|
||||
switch (type) {
|
||||
case "comment":
|
||||
return "text-gray-500 italic"; // muted + italic to recede visually
|
||||
return "text-syntax-comment italic"; // muted + italic to recede visually
|
||||
case "string":
|
||||
return "text-emerald-300"; // green, shared with diff-add text
|
||||
return "text-syntax-string"; // green, shared with diff-add text
|
||||
case "number":
|
||||
return "text-orange-300"; // orange, shared with boolean literals
|
||||
return "text-syntax-number"; // orange, shared with boolean literals
|
||||
case "keyword":
|
||||
return "text-violet-300"; // violet, shared with diff-meta headers
|
||||
return "text-syntax-keyword"; // violet, shared with diff-meta headers
|
||||
case "builtin":
|
||||
return "text-sky-300";
|
||||
return "text-syntax-builtin";
|
||||
case "function":
|
||||
return "text-yellow-200"; // yellow, shared with HTML attr names
|
||||
return "text-syntax-function"; // yellow, shared with HTML attr names
|
||||
case "operator":
|
||||
return "text-pink-300";
|
||||
return "text-syntax-operator";
|
||||
case "punctuation":
|
||||
return "text-gray-400"; // slightly brighter than comments, dimmer than plain
|
||||
return "text-syntax-punctuation"; // brighter than comments, dimmer than plain
|
||||
case "property":
|
||||
return "text-cyan-300";
|
||||
return "text-syntax-property";
|
||||
case "tag":
|
||||
return "text-rose-300";
|
||||
return "text-syntax-tag";
|
||||
case "attr":
|
||||
return "text-yellow-200"; // matches "function" colour by design
|
||||
return "text-syntax-function"; // matches "function" colour by design
|
||||
case "variable":
|
||||
return "text-amber-300";
|
||||
return "text-syntax-function"; // amber family, same as functions/attrs
|
||||
case "boolean":
|
||||
return "text-orange-300"; // literals grouped with numbers by colour
|
||||
return "text-syntax-number"; // literals grouped with numbers by colour
|
||||
case "diff-add":
|
||||
return "text-emerald-300 bg-emerald-500/10"; // green text + faint green row tint
|
||||
return "text-syntax-add bg-status-success/20"; // green text + theme-aware green row tint
|
||||
case "diff-del":
|
||||
return "text-red-300 bg-red-500/10"; // red text + faint red row tint
|
||||
return "text-syntax-del bg-status-danger/20"; // red text + theme-aware red row tint
|
||||
case "diff-meta":
|
||||
return "text-violet-300";
|
||||
return "text-syntax-keyword";
|
||||
case "plain":
|
||||
default:
|
||||
return "text-gray-200"; // default body text colour
|
||||
return "text-syntax-plain"; // default body text colour
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file laneOrderStorage.ts
|
||||
* @description Persists the Workspace lane strip's user-chosen card order to
|
||||
* localStorage, the same read/validate/fallback convention as
|
||||
* splitViewStorage.ts.
|
||||
*
|
||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||
* a per-viewer preference, like the split-pane layout beside it, and nothing
|
||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
||||
* opinion about it. Ceiling: the order does not follow the user to another
|
||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||
* must.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "agenthub.workspace.laneOrder";
|
||||
|
||||
export function readLaneOrder(): number[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is number => typeof id === "number");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLaneOrder(ids: number[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a stored id order to a freshly fetched lane list.
|
||||
*
|
||||
* Ids the stored order never saw — a lane created since, or created in another
|
||||
* tab — keep their server order (id ascending) and land at the END, so a new
|
||||
* lane appears predictably rather than wherever a stale index puts it. Stored
|
||||
* ids naming a lane that no longer exists are simply skipped.
|
||||
*
|
||||
* @param lanes Lanes in server order.
|
||||
* @param order Stored id order.
|
||||
* @returns A new array; the input is never mutated.
|
||||
*/
|
||||
export function sortByStoredOrder<T extends { id: number }>(lanes: T[], order: number[]): T[] {
|
||||
const rank = new Map(order.map((id, i) => [id, i]));
|
||||
const known = lanes
|
||||
.filter((l) => rank.has(l.id))
|
||||
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
|
||||
const fresh = lanes.filter((l) => !rank.has(l.id));
|
||||
return [...known, ...fresh];
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the lane at `from` to index `to`, returning the resulting id order.
|
||||
* Out-of-range indices yield the unchanged order rather than a hole.
|
||||
*/
|
||||
export function moveLane(ids: number[], from: number, to: number): number[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= ids.length || to >= ids.length) return ids;
|
||||
const next = [...ids];
|
||||
next.splice(to, 0, ...next.splice(from, 1));
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file Browser-notification preferences — the single definition of the shape,
|
||||
* the defaults, and the `localStorage` read/write. Settings (which edits them)
|
||||
* and `useNotifications` (which obeys them) both import from here; they used to
|
||||
* carry private copies, so adding a notification type meant editing the same
|
||||
* literal twice and a miss went unnoticed until a toggle did nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
export interface NotifPrefs {
|
||||
/** Master switch; when false no notification fires, whatever the flags say. */
|
||||
enabled: boolean;
|
||||
onNewSession: boolean;
|
||||
onSessionError: boolean;
|
||||
/** Claude is BLOCKED on the user — a permission prompt or a question. */
|
||||
onAwaitingInput: boolean;
|
||||
/** Claude finished its turn and is waiting for the next prompt (`Stop`). */
|
||||
onTurnComplete: boolean;
|
||||
/** The whole session closed (`SessionEnd`), not just one turn. */
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
}
|
||||
|
||||
// The two "Claude is waiting for you" types default ON: they are the reason
|
||||
// notifications exist (issue #29 — the user walks away during a long run and
|
||||
// needs to know when they are the blocker). The rest stay as they were.
|
||||
export const defaultNotifPrefs: NotifPrefs = {
|
||||
enabled: false,
|
||||
onNewSession: false,
|
||||
onSessionError: true,
|
||||
onAwaitingInput: true,
|
||||
onTurnComplete: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
|
||||
/** Reads prefs, merged over the defaults so a stored blob written by an older
|
||||
* build (missing the newer flags) still yields a complete object. */
|
||||
export function loadNotifPrefs(): NotifPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_KEY);
|
||||
if (!raw) return { ...defaultNotifPrefs };
|
||||
return { ...defaultNotifPrefs, ...(JSON.parse(raw) as Partial<NotifPrefs>) };
|
||||
} catch {
|
||||
return { ...defaultNotifPrefs };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNotifPrefs(prefs: NotifPrefs) {
|
||||
try {
|
||||
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
/* private mode / quota — the toggle still applies for this tab */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Shows a browser notification — the single `notify()` both
|
||||
* `useNotifications` (live events) and Settings' "Send Test Notification"
|
||||
* button call, so the test proves the real delivery path and can report
|
||||
* where it actually landed instead of a bare fire-and-forget.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type NotifyVia = "native" | "push" | "local" | "blocked" | "none";
|
||||
|
||||
export interface NotifyError {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
via: NotifyVia;
|
||||
errors: NotifyError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefers a server-relayed push (so it can arrive even if this tab isn't the
|
||||
* active one, or the browser is backgrounded) and falls back to a local
|
||||
* service-worker/`Notification` call when the server didn't deliver.
|
||||
* @param title Notification title.
|
||||
* @param body Notification body text.
|
||||
* @param url Same-origin path to focus/navigate to on click (e.g. the lane
|
||||
* that raised this notification). Omit for notifications with no target.
|
||||
*/
|
||||
export async function notify(title: string, body: string, url?: string): Promise<NotifyResult> {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") {
|
||||
return { via: "blocked", errors: [] };
|
||||
}
|
||||
|
||||
const errors: NotifyError[] = [];
|
||||
|
||||
// A 200 from /api/push/send means the request was ACCEPTED, not that anything
|
||||
// reached the user: with no push subscription (no service worker, a
|
||||
// non-secure origin, a browser that never subscribed) or a push service the
|
||||
// server cannot reach, it answers `{ok:true, native:false, pushed:0}` and
|
||||
// nobody is notified. Only `native` or `pushed > 0` is actual delivery —
|
||||
// anything else falls through to the local notification below, which is why
|
||||
// the fallback is keyed on the RESULT rather than on fetch throwing.
|
||||
try {
|
||||
const res = await fetch("/api/push/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body, url }),
|
||||
});
|
||||
const result = res.ok ? await res.json().catch(() => null) : null;
|
||||
if (result?.native) return { via: "native", errors };
|
||||
if (result && result.pushed > 0) return { via: "push", errors };
|
||||
if (!res.ok) errors.push({ statusCode: res.status });
|
||||
} catch (e) {
|
||||
errors.push({ message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
try {
|
||||
// Vector, not `/favicon.ico` (32x32 raster) — crisp at whatever size the
|
||||
// OS renders it, matching the icon server/lib/push.js already sends.
|
||||
const data = url ? { url } : undefined;
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(title, {
|
||||
body,
|
||||
icon: "/favicon.svg",
|
||||
silent: false,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
const n = new Notification(title, { body, icon: "/favicon.svg", data });
|
||||
if (url) {
|
||||
// No service worker to catch the click here — navigate this tab directly.
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
window.location.assign(url);
|
||||
};
|
||||
}
|
||||
}
|
||||
return { via: "local", errors };
|
||||
} catch (e) {
|
||||
errors.push({ message: e instanceof Error ? e.message : String(e) });
|
||||
return { via: "none", errors };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file terminalTheme.ts
|
||||
* @description The xterm colour theme for lane terminals. The terminal is
|
||||
* ALWAYS dark — Claude Code's TUI picks its own colours for a dark background,
|
||||
* so a light terminal would make its text unreadable — and is painted with the
|
||||
* palette of the DARK theme the user picked (`themeFor.dark`). Colours are read
|
||||
* from the CSS variables in `index.css` on a hidden probe carrying
|
||||
* `class="dark" data-theme=<id>`, so no hex is duplicated here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
/** Reads one CSS variable (an RGB triplet like `17 17 19`) by name. */
|
||||
export type VarReader = (name: string) => string;
|
||||
|
||||
function toHex(triplet: string): string | undefined {
|
||||
const parts = triplet
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return undefined;
|
||||
return "#" + parts.map((n) => Math.round(n).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/** Mix a hex colour toward white by `amount` (0..1) — the ANSI "bright" twin. */
|
||||
function lighten(hex: string | undefined, amount: number): string | undefined {
|
||||
if (!hex) return undefined;
|
||||
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
return (
|
||||
"#" +
|
||||
rgb
|
||||
.map((c) =>
|
||||
Math.round(c + (255 - c) * amount)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
)
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
|
||||
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
|
||||
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
|
||||
* theme (darcula's keyword is orange), and a program printing "magenta" should
|
||||
* get magenta.
|
||||
*/
|
||||
export function buildTerminalTheme(read: VarReader): ITheme {
|
||||
const v = (name: string) => toHex(read(name));
|
||||
const red = v("--status-danger");
|
||||
const green = v("--status-success");
|
||||
const yellow = v("--status-warning");
|
||||
const blue = v("--accent");
|
||||
const magenta = "#c678dd";
|
||||
const cyan = "#56b6c2";
|
||||
return {
|
||||
background: v("--surface-0"),
|
||||
foreground: v("--fg-primary"),
|
||||
cursor: v("--fg-primary"),
|
||||
cursorAccent: v("--surface-0"),
|
||||
selectionBackground: v("--border-light"),
|
||||
black: v("--border-light"),
|
||||
red,
|
||||
green,
|
||||
yellow,
|
||||
blue,
|
||||
magenta,
|
||||
cyan,
|
||||
white: v("--fg-secondary"),
|
||||
brightBlack: v("--fg-muted"),
|
||||
brightRed: lighten(red, 0.25),
|
||||
brightGreen: lighten(green, 0.25),
|
||||
brightYellow: lighten(yellow, 0.25),
|
||||
brightBlue: lighten(blue, 0.25),
|
||||
brightMagenta: lighten(magenta, 0.25),
|
||||
brightCyan: lighten(cyan, 0.25),
|
||||
brightWhite: v("--fg-primary"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the dark theme `id`'s variables on a hidden probe and build the theme. */
|
||||
export function terminalThemeFor(id: string): ITheme {
|
||||
const probe = document.createElement("div");
|
||||
probe.className = "dark";
|
||||
if (id !== "default") probe.dataset.theme = id;
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
try {
|
||||
const style = getComputedStyle(probe);
|
||||
return buildTerminalTheme((name) => style.getPropertyValue(name));
|
||||
} finally {
|
||||
probe.remove();
|
||||
}
|
||||
}
|
||||
+148
-9
@@ -2420,6 +2420,11 @@ export interface Lane {
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
tool: LaneTool;
|
||||
/** Name of the AI Router profile this lane runs against, or null for the
|
||||
* vendor's own API. Unlike `tool` this IS changeable after creation, but
|
||||
* only through `POST /api/lanes/:id/router/apply` — the column and the
|
||||
* lane's own config on disk must move together. */
|
||||
router_profile: string | null;
|
||||
/** For a managed worktree lane, the repo it was provisioned from. Null for
|
||||
* an adopted lane (it IS a source repo, not a worktree of one). */
|
||||
source_repo: string | null;
|
||||
@@ -2587,9 +2592,143 @@ export type LaneGitFacts =
|
||||
dirty: number;
|
||||
/** Files git has never seen. */
|
||||
untracked: number;
|
||||
/** The files behind the counts, capped; the counts themselves stay exact. */
|
||||
files: LaneGitFile[];
|
||||
/** Whether the repo has any remote configured at all. */
|
||||
hasRemote: boolean;
|
||||
/** `origin/<branch>`, or null when this branch has never been pushed. */
|
||||
upstream: string | null;
|
||||
/** Commits HEAD has that the upstream does not; null without an upstream
|
||||
* — not 0, which would claim "in sync" about a comparison never made. */
|
||||
ahead: number | null;
|
||||
/** Commits the upstream has that HEAD does not; null without an upstream. */
|
||||
behind: number | null;
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
/** One uncommitted file, as `GET /api/lanes/:id/git` reports it. */
|
||||
export interface LaneGitFile {
|
||||
/** Path relative to the repo root; for a rename, the new path. */
|
||||
path: string;
|
||||
/** The two-character `git status --porcelain` code, e.g. ` M`, `??`, `A `. */
|
||||
status: string;
|
||||
/** Lines added, or null when git has no delta for it (untracked, binary). */
|
||||
added: number | null;
|
||||
/** Lines removed, or null for the same reasons. */
|
||||
deleted: number | null;
|
||||
}
|
||||
|
||||
/** One entry of `git worktree list` for a repo. */
|
||||
export interface LaneWorktree {
|
||||
/** Absolute path of the worktree's directory. */
|
||||
path: string;
|
||||
/** The branch checked out there; absent for a detached worktree. */
|
||||
branch?: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
/** Which comparison the Changes panel is showing. */
|
||||
export type LaneChangeRange = "work" | "branch" | "commit";
|
||||
|
||||
/** One row of `GET /api/lanes/:id/git/log`. */
|
||||
export interface LaneCommit {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
/** ISO 8601 author date. */
|
||||
date: string;
|
||||
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
|
||||
refs: string[];
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
|
||||
export type LaneFileContent =
|
||||
| {
|
||||
available: true;
|
||||
path: string;
|
||||
rev: string;
|
||||
binary: boolean;
|
||||
truncated: boolean;
|
||||
lines: string[];
|
||||
/** Version stamp of a whole, textual `work` file — what a save sends
|
||||
* back as `baseSha`. Absent at a commit, or when binary/truncated. */
|
||||
sha?: string;
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
/** One entry of `GET /api/lanes/:id/git/tree` — a folded directory or a file. */
|
||||
export interface LaneTreeEntry {
|
||||
name: string;
|
||||
/** Path relative to the repo root. */
|
||||
path: string;
|
||||
dir: boolean;
|
||||
/** A changed file's porcelain code (` M`, `??`, `A `, ` D`…); absent when clean. */
|
||||
status?: string;
|
||||
/** A folder with a changed or untracked file anywhere under it. */
|
||||
changed?: true;
|
||||
}
|
||||
|
||||
/** One row of a rendered diff — both gutters already resolved server-side. */
|
||||
export interface LaneDiffLine {
|
||||
type: "ctx" | "add" | "del";
|
||||
/** Line number on the left (before) side; null on an added line. */
|
||||
oldLine: number | null;
|
||||
/** Line number on the right (after) side; null on a removed line. */
|
||||
newLine: number | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One `@@` block of a file's diff. */
|
||||
export interface LaneDiffHunk {
|
||||
header: string;
|
||||
/** The function/section name git puts after the `@@`, when it found one. */
|
||||
context: string;
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
lines: LaneDiffLine[];
|
||||
}
|
||||
|
||||
/** One changed file, as `GET /api/lanes/:id/git/changes` reports it. */
|
||||
export interface LaneChangeFile {
|
||||
path: string;
|
||||
/** Two-character porcelain code for the `work` range, `"M "`-style for `branch`. */
|
||||
status: string;
|
||||
untracked: boolean;
|
||||
/** The file has changes in the index (`work` range only). */
|
||||
staged?: boolean;
|
||||
/** The file has changes only in the working tree (`work` range only). */
|
||||
unstaged?: boolean;
|
||||
/** The file is in a merge conflict (`work` range only). */
|
||||
conflicted?: boolean;
|
||||
added: number | null;
|
||||
deleted: number | null;
|
||||
binary: boolean;
|
||||
/** Absent in overview mode (no `full`/`path`), `[]` for a binary file. */
|
||||
hunks?: LaneDiffHunk[];
|
||||
/** This file's patch was cut short by a size cap. */
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/changes`. */
|
||||
export type LaneChanges =
|
||||
| {
|
||||
available: true;
|
||||
range: LaneChangeRange;
|
||||
/** The lane's base branch name, for `range: "branch"`. */
|
||||
base: string | null;
|
||||
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
|
||||
baseRef: string | null;
|
||||
/** The commit being shown, for `range: "commit"`; its full sha. */
|
||||
sha: string | null;
|
||||
/** Lines of unchanged context each hunk carries. */
|
||||
context: number;
|
||||
files: LaneChangeFile[];
|
||||
/** A cap bit: the file list or some patch was cut short. */
|
||||
truncated: boolean;
|
||||
}
|
||||
| { available: false; range: LaneChangeRange; files: LaneChangeFile[] };
|
||||
|
||||
/** Aggregate counts across all lanes. */
|
||||
export interface LaneCounts {
|
||||
total: number;
|
||||
@@ -2673,7 +2812,7 @@ export const STATUS_CONFIG: Record<
|
||||
// Green: the agent is actively running a tool / doing work.
|
||||
working: {
|
||||
labelKey: "common:status.working",
|
||||
color: "text-emerald-400",
|
||||
color: "text-emerald-700 dark:text-emerald-400",
|
||||
bg: "bg-emerald-500/10 border-emerald-500/20",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
@@ -2681,21 +2820,21 @@ export const STATUS_CONFIG: Record<
|
||||
// on user input - attention may be required.
|
||||
waiting: {
|
||||
labelKey: "common:status.waiting",
|
||||
color: "text-yellow-400",
|
||||
color: "text-yellow-700 dark:text-yellow-400",
|
||||
bg: "bg-yellow-500/10 border-yellow-500/20",
|
||||
dot: "bg-yellow-400",
|
||||
},
|
||||
// Violet: the agent finished cleanly.
|
||||
completed: {
|
||||
labelKey: "common:status.completed",
|
||||
color: "text-violet-400",
|
||||
color: "text-violet-700 dark:text-violet-400",
|
||||
bg: "bg-violet-500/10 border-violet-500/20",
|
||||
dot: "bg-violet-400",
|
||||
},
|
||||
// Red: the agent ended in an error state.
|
||||
error: {
|
||||
labelKey: "common:status.error",
|
||||
color: "text-red-400",
|
||||
color: "text-red-700 dark:text-red-400",
|
||||
bg: "bg-red-500/10 border-red-500/20",
|
||||
dot: "bg-red-400",
|
||||
},
|
||||
@@ -2881,28 +3020,28 @@ export const SESSION_STATUS_CONFIG: Record<
|
||||
// Green: the session is live and running.
|
||||
active: {
|
||||
labelKey: "common:status.active",
|
||||
color: "text-emerald-400",
|
||||
color: "text-emerald-700 dark:text-emerald-400",
|
||||
bg: "bg-emerald-500/10 border-emerald-500/20",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
|
||||
waiting: {
|
||||
labelKey: "common:status.waiting",
|
||||
color: "text-yellow-400",
|
||||
color: "text-yellow-700 dark:text-yellow-400",
|
||||
bg: "bg-yellow-500/10 border-yellow-500/20",
|
||||
dot: "bg-yellow-400",
|
||||
},
|
||||
// Violet: the session ended cleanly (SessionEnd).
|
||||
completed: {
|
||||
labelKey: "common:status.completed",
|
||||
color: "text-violet-400",
|
||||
color: "text-violet-700 dark:text-violet-400",
|
||||
bg: "bg-violet-500/10 border-violet-500/20",
|
||||
dot: "bg-violet-400",
|
||||
},
|
||||
// Red: the session ended in an error state.
|
||||
error: {
|
||||
labelKey: "common:status.error",
|
||||
color: "text-red-400",
|
||||
color: "text-red-700 dark:text-red-400",
|
||||
bg: "bg-red-500/10 border-red-500/20",
|
||||
dot: "bg-red-400",
|
||||
},
|
||||
@@ -2912,7 +3051,7 @@ export const SESSION_STATUS_CONFIG: Record<
|
||||
// Muted slate distinguishes "given up / faded out" from yellow Waiting
|
||||
// (attention required).
|
||||
labelKey: "common:status.abandoned",
|
||||
color: "text-slate-400",
|
||||
color: "text-slate-600 dark:text-slate-400",
|
||||
bg: "bg-slate-500/10 border-slate-500/20",
|
||||
dot: "bg-slate-400",
|
||||
},
|
||||
|
||||
@@ -341,7 +341,9 @@ export function ActivityFeed() {
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("subtitle")}
|
||||
{paused && (
|
||||
<span className="ml-2 text-yellow-400">{t("paused", { count: bufferCount })}</span>
|
||||
<span className="ml-2 text-yellow-700 dark:text-yellow-400">
|
||||
{t("paused", { count: bufferCount })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* @file AiRouterConfig.tsx
|
||||
* @description The **AI Router** tab of Settings — CRUD for the named router
|
||||
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
|
||||
* at instead of the vendor's own API.
|
||||
*
|
||||
* A lane CHOOSES its router in the Add-lane modal, not here. But choosing one
|
||||
* copies it into that lane's own config, so editing or deleting a profile here
|
||||
* does reach every lane already using it — the server re-applies it, or resets
|
||||
* those lanes to native, and reports any lane it could not touch. That report
|
||||
* is surfaced: a rotated key that silently missed two lanes would leave the
|
||||
* user believing a revoked key is out of use when it is not.
|
||||
*
|
||||
* The built-in `native` row is rendered read-only: it is the vendor's own API,
|
||||
* and it exists so that "go back to native" is the same operation as "use a
|
||||
* router" rather than a special case.
|
||||
*
|
||||
* An API key is write-only here by design — the server never returns one, so
|
||||
* the form shows whether a key is saved, never the key.
|
||||
*
|
||||
* **Test** asks the endpoint itself before the profile is saved onto a lane.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
* ever discovered inside a lane's session otherwise, where the error surfaces
|
||||
* as a broken CLI rather than a bad URL.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Pencil, Trash2, Router as RouterIcon, Lock } from "lucide-react";
|
||||
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
apiKey: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
label: "",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<AiRouter[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Not an error — the profile itself saved. It is the lanes that did not all
|
||||
// follow, which the user must be told about rather than left to assume.
|
||||
const [laneWarning, setLaneWarning] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AiRouter | null>(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The router's own verdict on the form as it currently stands. Cleared on
|
||||
// every field change: a green "works" left sitting above an edited base URL
|
||||
// is a claim about a configuration that no longer exists.
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
// Distinct from "no profiles": an empty table rendered before the first fetch
|
||||
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
||||
// distinction for the same reason.
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// No dependencies, deliberately. `t` is NOT one: react-i18next returns a
|
||||
// stable `t`, but nothing in this component's contract guarantees that, and
|
||||
// the moment it changes identity per render this callback does too — which
|
||||
// re-fires the effect below, which calls load(), which renders again. That
|
||||
// loop also clears `error` on every pass, so a failed save would flash and
|
||||
// vanish. Keeping the message untranslated is the cheaper half of the trade.
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await api.aiRouters.list()).items);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Any edit to the form invalidates the last verdict — see `testResult`.
|
||||
useEffect(() => setTestResult(null), [form]);
|
||||
|
||||
const startAdd = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: AiRouter) => {
|
||||
setEditing(item);
|
||||
// No apiKey: the server never sent one. Blank means "keep what is stored".
|
||||
setForm({
|
||||
label: item.label,
|
||||
baseUrl: item.baseUrl,
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
/** Report any lane the server could not re-point, or clear a stale report. */
|
||||
const reportLanes = (lanes?: { updated: number; failed: string[] }) => {
|
||||
setLaneWarning(
|
||||
lanes && lanes.failed.length
|
||||
? t("aiRouter.lanesFailed", { count: lanes.failed.length, lanes: lanes.failed.join(", ") })
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask the endpoint itself. `name` is sent when editing so a profile whose key
|
||||
* the form cannot show is still testable — the server only honours it while
|
||||
* the base URL is unchanged, so a retyped endpoint needs a retyped key.
|
||||
*/
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await api.aiRouters.test({
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
|
||||
...(editing ? { name: editing.name } : {}),
|
||||
});
|
||||
setTestResult({
|
||||
ok: result.ok,
|
||||
text: result.ok
|
||||
? t("aiRouter.testOk", { model: result.message })
|
||||
: t("aiRouter.testFailed", {
|
||||
status: result.status ?? "—",
|
||||
message: result.message,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
// Send only the tiers that differ from the headline model, so the server
|
||||
// keeps filling the rest from it — that is what makes one symbolic name
|
||||
// enough for a router that wants the same value everywhere.
|
||||
const models: Partial<AiRouterModels> = {};
|
||||
for (const tier of TIERS) {
|
||||
const value = (form.models[tier] || "").trim();
|
||||
if (value && value !== form.model.trim()) models[tier] = value;
|
||||
}
|
||||
const body: AiRouterInput = {
|
||||
label: form.label.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
if (editing) reportLanes((await api.aiRouters.update(editing.name, body)).lanes);
|
||||
else await api.aiRouters.create(body);
|
||||
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (item: AiRouter) => {
|
||||
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
reportLanes((await api.aiRouters.remove(item.name)).lanes);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<RouterIcon className="h-4 w-4 text-fg-muted" />
|
||||
{t("aiRouter.title")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("aiRouter.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{laneWarning && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
|
||||
{laneWarning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.builtin && (
|
||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||
{t("aiRouter.builtinHint")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
||||
{item.baseUrl || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">{item.model}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
{item.builtin
|
||||
? "—"
|
||||
: item.hasKey
|
||||
? t("aiRouter.keySet")
|
||||
: t("aiRouter.keyMissing")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!item.builtin && (
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.edit")} ${item.label}`}
|
||||
onClick={() => startEdit(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.remove")} ${item.label}`}
|
||||
onClick={() => remove(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(loading || items.length <= 1) && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
|
||||
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
|
||||
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field
|
||||
id="router-label"
|
||||
label={t("aiRouter.fieldLabel")}
|
||||
hint={t("aiRouter.fieldLabelHint")}
|
||||
value={form.label}
|
||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-base-url"
|
||||
label={t("aiRouter.fieldBaseUrl")}
|
||||
hint={t("aiRouter.fieldBaseUrlHint")}
|
||||
value={form.baseUrl}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-api-key"
|
||||
label={t("aiRouter.fieldApiKey")}
|
||||
hint={
|
||||
editing && editing.hasKey
|
||||
? t("aiRouter.fieldApiKeyKeep")
|
||||
: t("aiRouter.fieldApiKeyHint")
|
||||
}
|
||||
value={form.apiKey}
|
||||
type="password"
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-model"
|
||||
label={t("aiRouter.fieldModel")}
|
||||
hint={t("aiRouter.fieldModelHint")}
|
||||
value={form.model}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
{t("aiRouter.perTier")}
|
||||
</summary>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{TIERS.map((tier) => (
|
||||
<Field
|
||||
key={tier}
|
||||
id={`router-model-${tier}`}
|
||||
label={t(`aiRouter.tier.${tier}`)}
|
||||
value={form.models[tier] || ""}
|
||||
mono
|
||||
placeholder={form.model}
|
||||
onChange={(v) =>
|
||||
setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<p
|
||||
data-testid="router-test-result"
|
||||
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
|
||||
testResult.ok
|
||||
? "border-status-success/40 bg-status-success/10 text-status-success"
|
||||
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{testResult.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="router-test"
|
||||
onClick={test}
|
||||
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
|
||||
className="mr-auto rounded-md border border-border-light px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
|
||||
>
|
||||
{testing ? t("aiRouter.testing") : t("aiRouter.test")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
|
||||
>
|
||||
{t("aiRouter.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{t("aiRouter.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
mono,
|
||||
type = "text",
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none ${
|
||||
mono ? "font-mono" : ""
|
||||
}`}
|
||||
/>
|
||||
{hint && <p className="mt-1 text-[10px] text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+148
-14
@@ -141,6 +141,8 @@ import type {
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcProfileUsage,
|
||||
CcUsageWindow,
|
||||
CcScope,
|
||||
CcSettingsSource,
|
||||
CcStatusline,
|
||||
@@ -968,7 +970,7 @@ type Tone =
|
||||
const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring: string }> = {
|
||||
sky: {
|
||||
iconBg: "bg-sky-500/10",
|
||||
iconText: "text-sky-300",
|
||||
iconText: "text-sky-700 dark:text-sky-300",
|
||||
bar: "bg-sky-500/40",
|
||||
ring: "ring-sky-500/20",
|
||||
},
|
||||
@@ -980,7 +982,7 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
|
||||
},
|
||||
violet: {
|
||||
iconBg: "bg-violet-500/10",
|
||||
iconText: "text-violet-300",
|
||||
iconText: "text-violet-700 dark:text-violet-300",
|
||||
bar: "bg-violet-500/40",
|
||||
ring: "ring-violet-500/20",
|
||||
},
|
||||
@@ -992,37 +994,37 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
|
||||
},
|
||||
fuchsia: {
|
||||
iconBg: "bg-fuchsia-500/10",
|
||||
iconText: "text-fuchsia-300",
|
||||
iconText: "text-fuchsia-700 dark:text-fuchsia-300",
|
||||
bar: "bg-fuchsia-500/40",
|
||||
ring: "ring-fuchsia-500/20",
|
||||
},
|
||||
cyan: {
|
||||
iconBg: "bg-cyan-500/10",
|
||||
iconText: "text-cyan-300",
|
||||
iconText: "text-cyan-700 dark:text-cyan-300",
|
||||
bar: "bg-cyan-500/40",
|
||||
ring: "ring-cyan-500/20",
|
||||
},
|
||||
pink: {
|
||||
iconBg: "bg-pink-500/10",
|
||||
iconText: "text-pink-300",
|
||||
iconText: "text-pink-700 dark:text-pink-300",
|
||||
bar: "bg-pink-500/40",
|
||||
ring: "ring-pink-500/20",
|
||||
},
|
||||
indigo: {
|
||||
iconBg: "bg-indigo-500/10",
|
||||
iconText: "text-indigo-300",
|
||||
iconText: "text-indigo-700 dark:text-indigo-300",
|
||||
bar: "bg-indigo-500/40",
|
||||
ring: "ring-indigo-500/20",
|
||||
},
|
||||
orange: {
|
||||
iconBg: "bg-orange-500/10",
|
||||
iconText: "text-orange-300",
|
||||
iconText: "text-orange-700 dark:text-orange-300",
|
||||
bar: "bg-orange-500/40",
|
||||
ring: "ring-orange-500/20",
|
||||
},
|
||||
teal: {
|
||||
iconBg: "bg-teal-500/10",
|
||||
iconText: "text-teal-300",
|
||||
iconText: "text-teal-700 dark:text-teal-300",
|
||||
bar: "bg-teal-500/40",
|
||||
ring: "ring-teal-500/20",
|
||||
},
|
||||
@@ -1034,7 +1036,7 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
|
||||
},
|
||||
rose: {
|
||||
iconBg: "bg-rose-500/10",
|
||||
iconText: "text-rose-300",
|
||||
iconText: "text-rose-700 dark:text-rose-300",
|
||||
bar: "bg-rose-500/40",
|
||||
ring: "ring-rose-500/20",
|
||||
},
|
||||
@@ -2719,7 +2721,7 @@ function CurrentConfigPanel({ sources }: { sources: CcSettingsSource[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2">
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||
<SettingsIcon className="w-3.5 h-3.5 text-violet-300/80" />
|
||||
<SettingsIcon className="w-3.5 h-3.5 text-violet-700/80 dark:text-violet-300/80" />
|
||||
<span className="text-sm font-medium text-fg-primary">Current configuration</span>
|
||||
<span className="text-[11px] text-fg-muted ml-auto">
|
||||
{setCount} option{setCount !== 1 ? "s" : ""} set · the rest use defaults
|
||||
@@ -3231,7 +3233,7 @@ function renderMemoryIndex(
|
||||
type="button"
|
||||
onClick={() => onJump(capturedTarget)}
|
||||
title={`${jumpTitle}: ${normalizeMemoryTarget(capturedTarget)}`}
|
||||
className="text-teal-300 hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
|
||||
className="text-teal-700 dark:text-teal-300 hover:text-teal-800 dark:hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -3384,7 +3386,7 @@ function MemoryPanel({
|
||||
{totalAuto > 0 && (
|
||||
<section className="pt-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BookOpen className="w-3.5 h-3.5 text-teal-300" />
|
||||
<BookOpen className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300" />
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-secondary">
|
||||
{t("memory.autoTitle")}
|
||||
</h3>
|
||||
@@ -3497,7 +3499,7 @@ function MemoryProjectGroup({
|
||||
open ? "" : "-rotate-90"
|
||||
}`}
|
||||
/>
|
||||
<FolderTree className="w-3.5 h-3.5 text-teal-300 flex-shrink-0" />
|
||||
<FolderTree className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300 flex-shrink-0" />
|
||||
<span className="font-mono text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{project}
|
||||
</span>
|
||||
@@ -3616,7 +3618,7 @@ function MemoryIndexCard({
|
||||
return (
|
||||
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
|
||||
<BookOpen className="w-3 h-3 text-teal-300 flex-shrink-0" />
|
||||
<BookOpen className="w-3 h-3 text-teal-700 dark:text-teal-300 flex-shrink-0" />
|
||||
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
|
||||
{item.name}
|
||||
</span>
|
||||
@@ -4232,6 +4234,18 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||
* list arrives — the whole point of the panel is comparing accounts, and one
|
||||
* button per row made the user do that by hand. The cost is bounded: the
|
||||
* live account is read from the CLI and never refreshed, and any other
|
||||
* profile is refreshed only when its token has actually expired, which
|
||||
* leaves it valid for hours of further opens. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
/** Per profile, so one slow account does not freeze the other rows. */
|
||||
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -4276,6 +4290,42 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
[load, onToast]
|
||||
);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setUsageBusy((prev) => ({ ...prev, [name]: true }));
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setUsageBusy((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every profile's quota, as soon as there is a list to ask about. Once per
|
||||
// profile per opening: a retry is the row's own button, so a failed account
|
||||
// is not re-asked on every render.
|
||||
useEffect(() => {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -4441,6 +4491,13 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
@@ -4459,6 +4516,83 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one.
|
||||
*
|
||||
* Deliberately not fetched with the profile list. Reading a saved profile's
|
||||
* usage means refreshing its OAuth token, and that rotates the credentials on
|
||||
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
|
||||
* on a render or a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
|
||||
@@ -132,7 +132,14 @@ interface SystemInfo {
|
||||
node_version: string;
|
||||
platform: string;
|
||||
ws_connections: number;
|
||||
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
|
||||
memory: {
|
||||
rss: number;
|
||||
heapTotal: number;
|
||||
heapUsed: number;
|
||||
external: number;
|
||||
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
|
||||
heapLimit?: number;
|
||||
};
|
||||
cpu_load: number[];
|
||||
arch: string;
|
||||
total_mem: number;
|
||||
@@ -169,7 +176,7 @@ function ChartTooltip({ x, y, children }: { x: number; y: number; children: Reac
|
||||
const nearRight = x > window.innerWidth - 200;
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
className="fixed z-50 px-2 py-1.5 text-xs bg-surface-2 border border-border rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
style={{
|
||||
left: nearRight ? x - 14 : x + 14,
|
||||
top: y - 10,
|
||||
@@ -205,27 +212,16 @@ function useTooltip() {
|
||||
return { show, move, hide, node };
|
||||
}
|
||||
|
||||
function cellColor(count: number, max: number) {
|
||||
if (count === 0) return "#161625";
|
||||
// Log scale + RGB interpolation across a wide color ramp for maximum perceptual range
|
||||
/** Heatmap cell fill: the page surface mixed toward the accent on a log
|
||||
* scale. Built from theme tokens so the ramp reads surface → accent in every
|
||||
* theme; the old fixed indigo ramp went near-black on light themes. Exported
|
||||
* for its test. */
|
||||
export function cellColor(count: number, max: number) {
|
||||
if (count === 0) return "rgb(var(--surface-0))";
|
||||
const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
|
||||
// Ramp: near-black indigo → deep indigo → bright indigo → lavender
|
||||
type RGB = [number, number, number];
|
||||
const stops: RGB[] = [
|
||||
[22, 20, 60], // near-black indigo
|
||||
[55, 48, 163], // deep indigo
|
||||
[99, 102, 241], // bright indigo
|
||||
[199, 210, 254], // lavender
|
||||
];
|
||||
const scaled = t * (stops.length - 1);
|
||||
const lo = Math.min(Math.floor(scaled), stops.length - 2);
|
||||
const frac = scaled - lo;
|
||||
const [r1, g1, b1]: RGB = stops[lo] as RGB;
|
||||
const [r2, g2, b2]: RGB = stops[lo + 1] as RGB;
|
||||
const r = Math.round(r1 + (r2 - r1) * frac);
|
||||
const g = Math.round(g1 + (g2 - g1) * frac);
|
||||
const b = Math.round(b1 + (b2 - b1) * frac);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
// Floor at 20% so a single event still reads as distinct from an empty day.
|
||||
const pct = Math.round(20 + 80 * t);
|
||||
return `color-mix(in srgb, rgb(var(--accent)) ${pct}%, rgb(var(--surface-0)))`;
|
||||
}
|
||||
|
||||
function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }>> }) {
|
||||
@@ -345,7 +341,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
|
||||
height: 13,
|
||||
borderRadius: 2,
|
||||
backgroundColor: cellColor(cell.count, maxCount),
|
||||
border: "1px solid rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgb(var(--border) / 0.4)",
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
@@ -367,7 +363,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
|
||||
height: 13,
|
||||
borderRadius: 2,
|
||||
backgroundColor: cellColor(v, maxCount),
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
border: "1px solid rgb(var(--border) / 0.5)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -586,7 +582,14 @@ function DonutChart({
|
||||
<div className="flex items-center justify-center gap-6 w-full">
|
||||
{node}
|
||||
<svg width={128} height={128} viewBox="0 0 128 128" className="flex-shrink-0">
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#1e1e2e" strokeWidth={stroke} />
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="rgb(var(--border))"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{segments.map(({ label, value, color }, i) => {
|
||||
const dash = (value / total) * circumference;
|
||||
const gap = circumference - dash;
|
||||
@@ -1003,7 +1006,7 @@ function AnalyticsTab() {
|
||||
raw={data ? totalTokens.toLocaleString() : undefined}
|
||||
sub={data ? `${cacheHitPct}${t("cacheHitRate")}` : undefined}
|
||||
icon={Cpu}
|
||||
color="text-violet-400"
|
||||
color="text-violet-700 dark:text-violet-400"
|
||||
loading={!data}
|
||||
/>
|
||||
<StatPill
|
||||
@@ -1025,7 +1028,7 @@ function AnalyticsTab() {
|
||||
raw={data ? data.overview.total_events.toLocaleString() : undefined}
|
||||
sub={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
|
||||
icon={Zap}
|
||||
color="text-yellow-400"
|
||||
color="text-yellow-700 dark:text-yellow-400"
|
||||
loading={!data}
|
||||
/>
|
||||
</div>
|
||||
@@ -1148,7 +1151,9 @@ function AnalyticsTab() {
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-fg-muted">
|
||||
<span>{t("cacheEfficiency")}</span>
|
||||
<span className="text-violet-400 font-mono">{cacheHitPct}%</span>
|
||||
<span className="text-violet-700 dark:text-violet-400 font-mono">
|
||||
{cacheHitPct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1173,12 +1178,12 @@ function AnalyticsTab() {
|
||||
{
|
||||
label: t("common:token.cacheRead"),
|
||||
value: data?.tokens.total_cache_read ?? 0,
|
||||
color: "text-violet-400",
|
||||
color: "text-violet-700 dark:text-violet-400",
|
||||
},
|
||||
{
|
||||
label: t("common:token.cacheWrite"),
|
||||
value: data?.tokens.total_cache_write ?? 0,
|
||||
color: "text-yellow-400",
|
||||
color: "text-yellow-700 dark:text-yellow-400",
|
||||
},
|
||||
{ label: t("common:total"), value: totalTokens, color: "text-fg-primary" },
|
||||
].map(({ label, value, color }) => (
|
||||
@@ -1324,7 +1329,7 @@ function AnalyticsTab() {
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border text-xs flex justify-between">
|
||||
<span className="text-fg-muted">{t("common:total")}</span>
|
||||
<span className="text-cyan-400 font-mono">
|
||||
<span className="text-cyan-700 dark:text-cyan-400 font-mono">
|
||||
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1562,10 +1567,10 @@ function SystemHealthTab() {
|
||||
|
||||
const memUsedPct =
|
||||
info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0;
|
||||
const heapUsedPct =
|
||||
info.server.memory.heapTotal > 0
|
||||
? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100
|
||||
: 0;
|
||||
// Against the heap LIMIT: heapTotal is only what V8 has grown to so far, so
|
||||
// used/total reads 70-90% on a healthy process and dragged the health score.
|
||||
const heapMax = info.server.memory.heapLimit || info.server.memory.heapTotal;
|
||||
const heapUsedPct = heapMax > 0 ? (info.server.memory.heapUsed / heapMax) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalEntries,
|
||||
@@ -1701,7 +1706,7 @@ function SystemHealthTab() {
|
||||
</Tip>
|
||||
<Tip
|
||||
block
|
||||
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
|
||||
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapLimit || info.server.memory.heapTotal)} limit\nAllocated: ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
@@ -1754,7 +1759,14 @@ function SystemHealthTab() {
|
||||
viewBox="0 0 96 96"
|
||||
className="flex-shrink-0 cursor-default"
|
||||
>
|
||||
<circle cx="48" cy="48" r="38" fill="none" stroke="#1e1e2e" strokeWidth="14" />
|
||||
<circle
|
||||
cx="48"
|
||||
cy="48"
|
||||
r="38"
|
||||
fill="none"
|
||||
stroke="rgb(var(--border))"
|
||||
strokeWidth="14"
|
||||
/>
|
||||
{(() => {
|
||||
const r = 38,
|
||||
cx = 48,
|
||||
@@ -1875,7 +1887,14 @@ function SystemHealthTab() {
|
||||
raw={`Score: ${healthScore.toFixed(1)} / 100\n\n• Success Rate (40%): ${successRate.toFixed(1)}%\n• Cache Hit (25%): ${cacheHitRate.toFixed(1)}%\n• Error Avoidance (25%): ${(100 - errorRate).toFixed(1)}%\n• Memory Health (10%): ${(100 - heapUsedPct).toFixed(1)}%`}
|
||||
>
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" className="cursor-default">
|
||||
<circle cx="60" cy="60" r="48" fill="none" stroke="#1e1e2e" strokeWidth="10" />
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="48"
|
||||
fill="none"
|
||||
stroke="rgb(var(--border))"
|
||||
strokeWidth="10"
|
||||
/>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
@@ -1947,7 +1966,7 @@ function SystemHealthTab() {
|
||||
>
|
||||
<div className="text-center cursor-default">
|
||||
<p className="text-[9px] text-fg-muted uppercase">Compact</p>
|
||||
<p className="text-xs font-mono font-bold text-violet-400">
|
||||
<p className="text-xs font-mono font-bold text-violet-700 dark:text-violet-400">
|
||||
{workflow.compaction?.totalCompactions ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
@@ -2029,7 +2048,7 @@ function SystemHealthTab() {
|
||||
<div className="card p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="w-4 h-4 text-violet-400" />
|
||||
<BarChart3 className="w-4 h-4 text-violet-700 dark:text-violet-400" />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider">Concurrency</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-fg-muted">{lanes.length} intervals</span>
|
||||
@@ -2051,7 +2070,7 @@ function SystemHealthTab() {
|
||||
? "#fbbf24"
|
||||
: lane.count > 0
|
||||
? "#34d399"
|
||||
: "#1e1e2e";
|
||||
: "rgb(var(--border))";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -2165,7 +2184,7 @@ function SystemHealthTab() {
|
||||
<div className="card p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GitBranch className="w-4 h-4 text-violet-400" />
|
||||
<GitBranch className="w-4 h-4 text-violet-700 dark:text-violet-400" />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||||
Subagent Effectiveness
|
||||
</span>
|
||||
@@ -2275,7 +2294,7 @@ function SystemHealthTab() {
|
||||
<div className="card p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="w-4 h-4 text-cyan-400" />
|
||||
<Cpu className="w-4 h-4 text-cyan-700 dark:text-cyan-400" />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider">Platform</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-fg-muted">{info.server.node_version}</span>
|
||||
@@ -2613,7 +2632,7 @@ export function Dashboard() {
|
||||
label={t("activeSubagents")}
|
||||
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
|
||||
icon={GitBranch}
|
||||
accentColor="text-violet-400"
|
||||
accentColor="text-violet-700 dark:text-violet-400"
|
||||
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
|
||||
loading={!stats}
|
||||
/>
|
||||
@@ -2622,7 +2641,7 @@ export function Dashboard() {
|
||||
value={stats ? fmt(stats.events_today) : ""}
|
||||
raw={stats ? stats.events_today.toLocaleString() : undefined}
|
||||
icon={Zap}
|
||||
accentColor="text-yellow-400"
|
||||
accentColor="text-yellow-700 dark:text-yellow-400"
|
||||
loading={!stats}
|
||||
/>
|
||||
<StatCard
|
||||
@@ -2630,7 +2649,7 @@ export function Dashboard() {
|
||||
value={stats ? fmt(stats.total_events) : ""}
|
||||
raw={stats ? stats.total_events.toLocaleString() : undefined}
|
||||
icon={Activity}
|
||||
accentColor="text-violet-400"
|
||||
accentColor="text-violet-700 dark:text-violet-400"
|
||||
loading={!stats}
|
||||
/>
|
||||
<StatCard
|
||||
@@ -2714,7 +2733,7 @@ export function Dashboard() {
|
||||
empty rather than as a misalignment. */}
|
||||
{!hasChildren && !isSubagent && (
|
||||
<span
|
||||
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-violet-400/70"
|
||||
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-violet-700/70 dark:text-violet-400/70"
|
||||
aria-hidden="true"
|
||||
title={t("common:noSubagents", "No subagents")}
|
||||
>
|
||||
@@ -2722,7 +2741,7 @@ export function Dashboard() {
|
||||
</span>
|
||||
)}
|
||||
{isSubagent && (
|
||||
<GitBranch className="w-3 h-3 text-violet-400 flex-shrink-0" />
|
||||
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<AgentCard
|
||||
@@ -2751,7 +2770,7 @@ export function Dashboard() {
|
||||
onClick={() =>
|
||||
setExpandedAgents((prev) => new Set([...prev, agent.id]))
|
||||
}
|
||||
className="ml-7 mt-1 text-[11px] text-violet-400 hover:text-violet-300 transition-colors"
|
||||
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
|
||||
>
|
||||
{t("common:subagent_label", { count: totalDesc })}
|
||||
{activeDesc > 0 && (
|
||||
|
||||
@@ -664,23 +664,29 @@ export function SessionDetail() {
|
||||
}`}
|
||||
>
|
||||
<ReasonIcon
|
||||
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-300"}`}
|
||||
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-700 dark:text-yellow-300"}`}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-200"}`}
|
||||
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-800 dark:text-yellow-200"}`}
|
||||
>
|
||||
{t("detail.waitingBanner.title")}
|
||||
{cfg && (
|
||||
<span className={urgent ? "text-status-warning/90" : "text-yellow-300/80"}>
|
||||
<span
|
||||
className={
|
||||
urgent
|
||||
? "text-status-warning/90"
|
||||
: "text-yellow-700/80 dark:text-yellow-300/80"
|
||||
}
|
||||
>
|
||||
{" · "}
|
||||
{t(cfg.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-400/60"}`}
|
||||
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-700/60 dark:text-yellow-400/60"}`}
|
||||
>
|
||||
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
|
||||
</div>
|
||||
@@ -688,7 +694,7 @@ export function SessionDetail() {
|
||||
{session.awaiting_input_since && (
|
||||
<span
|
||||
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
|
||||
urgent ? "text-status-warning/80" : "text-yellow-400/70"
|
||||
urgent ? "text-status-warning/80" : "text-yellow-700/70 dark:text-yellow-400/70"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -736,7 +742,7 @@ export function SessionDetail() {
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "agents"
|
||||
? "border-violet-500 text-violet-400"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
@@ -750,7 +756,7 @@ export function SessionDetail() {
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "conversation"
|
||||
? "border-violet-500 text-violet-400"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
@@ -764,7 +770,7 @@ export function SessionDetail() {
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "timeline"
|
||||
? "border-violet-500 text-violet-400"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
@@ -794,7 +800,7 @@ export function SessionDetail() {
|
||||
{workflows.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Workflow className="w-3.5 h-3.5 text-violet-400" />
|
||||
<Workflow className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
{wfT("runs.sessionTitle")}
|
||||
<span className="text-fg-muted font-mono">· {workflows.length}</span>
|
||||
</h3>
|
||||
@@ -807,7 +813,7 @@ export function SessionDetail() {
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<Bot className="w-3.5 h-3.5 text-violet-400" />
|
||||
<Bot className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
{t("detail.agents")}
|
||||
<span className="text-fg-muted font-mono">· {agents.length}</span>
|
||||
</h3>
|
||||
@@ -885,7 +891,7 @@ export function SessionDetail() {
|
||||
)}
|
||||
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
|
||||
{isSubagent && (
|
||||
<GitBranch className="w-3 h-3 text-violet-400 flex-shrink-0" />
|
||||
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<AgentCard
|
||||
@@ -928,7 +934,7 @@ export function SessionDetail() {
|
||||
onClick={() =>
|
||||
setExpandedAgents((prev) => new Set([...prev, agent.id]))
|
||||
}
|
||||
className="ml-7 mt-1 text-[11px] text-violet-400 hover:text-violet-300 transition-colors"
|
||||
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
|
||||
>
|
||||
{t("common:subagent_label", { count: totalDesc })}
|
||||
</button>
|
||||
|
||||
@@ -436,7 +436,7 @@ export function Sessions() {
|
||||
</p>
|
||||
{session.source && session.source !== "local" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
|
||||
>
|
||||
<Server className="w-2.5 h-2.5" />
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragmen
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DollarSign,
|
||||
Palette,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
@@ -71,6 +72,8 @@ import {
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
XCircle,
|
||||
Server,
|
||||
Bell,
|
||||
@@ -109,11 +112,14 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { sagiPrefs } from "../components/Sagi/prefs";
|
||||
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
|
||||
import { notify } from "../lib/notify";
|
||||
import { Tip } from "../components/Tip";
|
||||
import { ImportHistory } from "../components/ImportHistory";
|
||||
import { RemoteSources } from "../components/RemoteSources";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import type { ModelPricing, WSMessage } from "../lib/types";
|
||||
|
||||
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
||||
@@ -124,6 +130,7 @@ const SETTINGS_SECTIONS: {
|
||||
fallback?: string;
|
||||
Icon: typeof DollarSign;
|
||||
}[] = [
|
||||
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
|
||||
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
|
||||
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
|
||||
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
||||
@@ -142,40 +149,6 @@ const SETTINGS_SECTIONS: {
|
||||
{ id: "about", labelKey: "about.title", Icon: Server },
|
||||
];
|
||||
|
||||
// ─── Notification preferences ───
|
||||
|
||||
const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
interface NotifPrefs {
|
||||
enabled: boolean;
|
||||
onNewSession: boolean;
|
||||
onSessionError: boolean;
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
}
|
||||
|
||||
const defaultNotif: NotifPrefs = {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
|
||||
function loadNotifPrefs(): NotifPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_KEY);
|
||||
if (!raw) return { ...defaultNotif };
|
||||
return { ...defaultNotif, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ...defaultNotif };
|
||||
}
|
||||
}
|
||||
|
||||
function saveNotifPrefs(prefs: NotifPrefs) {
|
||||
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
interface EditRow {
|
||||
@@ -399,7 +372,7 @@ function PricingInfoTooltip() {
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: pos.left, top: pos.top, width: 320 }}
|
||||
>
|
||||
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
||||
@@ -600,7 +573,10 @@ export function Settings() {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm === "granted") {
|
||||
updateNotifPrefs({ enabled: true });
|
||||
await subscribeToPush();
|
||||
// A browser with no service worker / on a non-secure origin rejects here;
|
||||
// notifications still work through the local fallback in useNotifications,
|
||||
// so this must not blow up the enable flow.
|
||||
await subscribeToPush().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -904,7 +880,9 @@ export function Settings() {
|
||||
// just Sonnet 5), so a future model with a launch promo needs no code change.
|
||||
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-violet-300/70">{t(labelKey)}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<input
|
||||
type={opts.date ? "text" : "number"}
|
||||
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
|
||||
@@ -920,7 +898,7 @@ export function Settings() {
|
||||
<td colSpan={10} className="px-4 pb-3 pt-2">
|
||||
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[11px] font-semibold text-violet-300 uppercase tracking-wider">
|
||||
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
|
||||
{t("pricing.introRatesTitle")}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
|
||||
@@ -1107,6 +1085,8 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppearanceSettings />
|
||||
|
||||
{/* ─── MODEL PRICING ─── */}
|
||||
<section id="pricing" className="scroll-mt-24">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
@@ -1209,7 +1189,7 @@ export function Settings() {
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary">
|
||||
{rule.display_name}
|
||||
{rule.intro_until && (
|
||||
<div className="text-[11px] font-normal text-violet-400/80 mt-0.5">
|
||||
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
|
||||
{t("pricing.introNote", {
|
||||
input: rule.intro_input_per_mtok,
|
||||
output: rule.intro_output_per_mtok,
|
||||
@@ -1221,7 +1201,7 @@ export function Settings() {
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.input_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-400/80">
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_input_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
@@ -1229,7 +1209,7 @@ export function Settings() {
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.output_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-400/80">
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_output_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
@@ -1237,7 +1217,7 @@ export function Settings() {
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_read_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-400/80">
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_read_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
@@ -1245,7 +1225,7 @@ export function Settings() {
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-400/80">
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
@@ -1253,7 +1233,7 @@ export function Settings() {
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_1h_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-400/80">
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_1h_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
@@ -1577,6 +1557,14 @@ export function Settings() {
|
||||
{t("notifications.notifyWhen")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
||||
<HelpCircle className="w-4 h-4 text-status-warning flex-shrink-0" />
|
||||
<Toggle
|
||||
checked={notifPrefs.onAwaitingInput}
|
||||
onChange={(v) => updateNotifPrefs({ onAwaitingInput: v })}
|
||||
label={t("notifications.awaitingInput")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
||||
<Play className="w-4 h-4 text-status-success flex-shrink-0" />
|
||||
<Toggle
|
||||
@@ -1586,7 +1574,15 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
||||
<CheckCircle className="w-4 h-4 text-violet-400 flex-shrink-0" />
|
||||
<MessageSquare className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<Toggle
|
||||
checked={notifPrefs.onTurnComplete}
|
||||
onChange={(v) => updateNotifPrefs({ onTurnComplete: v })}
|
||||
label={t("notifications.turnComplete")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
||||
<CheckCircle className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
|
||||
<Toggle
|
||||
checked={notifPrefs.onSessionComplete}
|
||||
onChange={(v) => updateNotifPrefs({ onSessionComplete: v })}
|
||||
@@ -1614,15 +1610,27 @@ export function Settings() {
|
||||
<div className="pt-3 border-t border-border">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted")
|
||||
return;
|
||||
await fetch("/api/push/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: t("notifications.testTitle"),
|
||||
body: t("notifications.testBody"),
|
||||
}),
|
||||
// Same notify() the live events use, so the test proves the
|
||||
// real path — and reports WHERE it landed, since a push that
|
||||
// reaches nobody used to look exactly like success.
|
||||
const r = await notify(
|
||||
t("notifications.testTitle"),
|
||||
t("notifications.testBody")
|
||||
);
|
||||
const reason = r.errors
|
||||
.map((e) => [e.statusCode, e.message].filter(Boolean).join(" "))
|
||||
.join("; ");
|
||||
setActionResult({
|
||||
key: "notif-test",
|
||||
isError: r.via === "none" || r.via === "blocked",
|
||||
message:
|
||||
r.via === "push" || r.via === "native"
|
||||
? t("notifications.testSent")
|
||||
: r.via === "local"
|
||||
? t("notifications.testLocal")
|
||||
: r.via === "blocked"
|
||||
? t("notifications.testBlocked")
|
||||
: `${t("notifications.testFailed")}${reason ? ` (${reason})` : ""}`,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors"
|
||||
@@ -1630,6 +1638,7 @@ export function Settings() {
|
||||
<Zap className="w-3 h-3" />
|
||||
{t("notifications.sendTest")}
|
||||
</button>
|
||||
<div className="mt-2">{actionBanner(["notif-test"])}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1681,9 +1690,11 @@ export function Settings() {
|
||||
const tableIcons: Record<string, React.ReactNode> = {
|
||||
sessions: <Layers className="w-4 h-4 text-blue-500" />,
|
||||
agents: <Users className="w-4 h-4 text-status-success" />,
|
||||
events: <Activity className="w-4 h-4 text-violet-400" />,
|
||||
events: <Activity className="w-4 h-4 text-violet-700 dark:text-violet-400" />,
|
||||
token_usage: <Coins className="w-4 h-4 text-status-warning" />,
|
||||
model_pricing: <BarChart3 className="w-4 h-4 text-cyan-400" />,
|
||||
model_pricing: (
|
||||
<BarChart3 className="w-4 h-4 text-cyan-700 dark:text-cyan-400" />
|
||||
),
|
||||
};
|
||||
const tableLabels: Record<string, string> = {
|
||||
sessions: t("tables.sessions"),
|
||||
@@ -1718,7 +1729,7 @@ export function Settings() {
|
||||
})()}
|
||||
<div className="bg-surface-2 rounded-lg px-3 py-3 border-l-2 border-indigo-500/20">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<HardDrive className="w-4 h-4 text-indigo-400" />
|
||||
<HardDrive className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("data.dbSize")}
|
||||
</p>
|
||||
@@ -1863,7 +1874,7 @@ export function Settings() {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Server className="w-4 h-4 text-indigo-400" />
|
||||
<Server className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.release")}
|
||||
</p>
|
||||
@@ -1901,7 +1912,7 @@ export function Settings() {
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Globe className="w-4 h-4 text-violet-400" />
|
||||
<Globe className="w-4 h-4 text-violet-700 dark:text-violet-400" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.platform")}
|
||||
</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user