Compare commits

...

13 Commits

Author SHA1 Message Date
nntrivi2001 fcf8d8010a fix: v2.5.6 — Windows panes without sh, UI build without node on PATH
- Windows panes: a WinGet tmux runs native programs and there is no `sh`, so
  every pane's `sh -c` wrapper died at once ("Command failed: tmux set-option
  … mouse on"). paneArgv runs the CLI itself on win32, and a Terminal gets
  tmux's own default shell (newSession drops the `--` for an empty argv).
- Plugin UI build: client deps install with --ignore-scripts and tsc/vite run
  by the running node instead of `npm run build`. On Windows esbuild's
  postinstall went through cmd.exe with no `node` on the server's PATH, both
  installs failed, and Update now kept the previous UI. A test keeps the two
  build steps equal to client/package.json's build script.
- Update all: Atomics.wait instead of a `sleep` binary Windows lacks.
2026-10-06 23:17:42 +07:00
nntrivi2001 5fe5d51b1b feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
2026-10-06 22:48:09 +07:00
nntrivi2001 dd9b23106c feat: v2.5.4 — Query Explorer folded into Sessions, Windows picker and paths
- Sessions page tabs: Sessions · Events · Agents. QueryExplorer.tsx became
  AgentsTable.tsx (agents only — sessions and events already had tabs);
  /query redirects to /sessions?view=agents and leaves the sidebar.
  GET /api/query is unchanged and still answers all three entities.
- Windows picker opens in front: -EncodedCommand script spends the hidden
  first ShowWindow that windowsHide gives the process, then brings the TopMost
  owner forward via AttachThreadInput (v2.5.3's owner was itself hidden).
- Session page Resume/Fork/Open pane: owningLane and sessionWorktreeName
  match Windows folders (either separator), with a test.
2026-10-06 21:56:51 +07:00
nntrivi2001 af014d9829 fix: v2.5.3 — Windows workspace folders, picker on top, no console flashes
A workspace could not be created on Windows: "cwd must be an absolute path"
for D:\…, because createLane/canonicalCwd tested for a leading "/".

- lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a
  missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too
- native-dialog.js: the Windows picker gets an invisible TopMost owner form,
  shown first — an ownerless dialog from the background server opened behind
  the browser, or hidden
- windows-hide.js: every child process on Windows starts with windowsHide,
  required first by the server and the detached scripts (a console window
  flashed per git/npm call)
- Confirm/update/clear-data modals and the quota panel scroll instead of
  overflowing a short window
2026-10-06 20:22:34 +07:00
nntrivi2001 f8acd5fc29 fix: v2.5.2 — plugin bootstrap exports before it runs (v2.5.1 update crash)
v2.5.1's update died at "bootstrap" on every platform: boot.npmCommand is
not a function. Run as the entry, plugin-bootstrap.js calls bootstrap() →
plugin-open.js, which requires plugin-bootstrap back and got the module.exports
of that moment — {} — because it was assigned at the bottom, below the entry
block. plugin-open.js already exported first for the same reason.

- module.exports moved above the entry block in plugin-bootstrap.js
- A test checks both entry scripts assign module.exports before their entry
  block (it fails on v2.5.1's file); requiring the module, as the other tests
  do, never takes the entry path
2026-10-06 19:17:52 +07:00
nntrivi2001 be75572977 fix: v2.5.1 — plugin npm runs no longer depend on PATH (Windows update failure)
A Windows self-update failed at "bootstrap": 'npm.cmd' is not recognized.
The update chain runs with the dashboard server's environment, whose PATH
need not reach npm.cmd, and the bootstrap looked npm up through cmd.exe
(shell: true, which also printed DEP0190).

- npmCommand() in scripts/plugin-bootstrap.js runs the npm bundled with the
  running node (npm-cli.js beside process.execPath, Windows and Unix
  layouts) with no shell, and puts that node's directory first on PATH (kept
  under the env's own Path/PATH key) so npm scripts find the same node; npm
  on PATH only when that node ships none
- Used by the runtime install, the UI build (plugin-open.js) and the
  doctor's npm row (plugin-doctor.js)
2026-10-06 19:08:38 +07:00
nntrivi2001 03ecc9e29a feat: start-at-login is a Settings switch, off by default (v2.5.0)
- Settings → About → "Start AgentHub automatically" (AutostartSettings.tsx,
  GET/PUT /api/settings/autostart, server/lib/autostart.js). The switch is
  the systemd --user unit's / LaunchAgent's enabled state: on writes a unit
  for the running server when none exists and enables it, off disables it;
  neither starts or stops the server. A foreign unit is never touched
  (409 EFOREIGN)
- The plugin bootstrap no longer installs a unit on every install: it
  installs or starts one only while the user has it enabled, and a rewrite
  keeps the enabled state (writeUserService({ enable })). An install that
  already had the unit enabled stays on. A stale-but-enabled unit beside a
  live detached server is repointed, no longer started onto the bound port
- unitIsEnabled / setUnitEnabled in systemd-service.js and launchd-service.js

Also:
- fix(workspace): status bar no longer covers the chat box below md (#61)
2026-10-06 18:53:10 +07:00
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00
nntrivi2001 837da6e5de fix: v2.3.1 — correct version, say why a plugin update failed, gitignored files in Files
- v2.3.0's tree reported 2.2.1, so every install saw an update forever;
  package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the
  snapshot now read 2.3.1
- A failed plugin-update step records the tail of the command's stdout and
  stderr plus its exit code or signal (failureText); plugin-bootstrap.js
  prints its refusal on stdout, which the verdict used to drop. Update all
  uses the same text
- updates.test.js never applies an update: it skips when the checkout is
  behind a release (apply ran npm run setup and pruned dev dependencies)
- Files tree lists gitignored files and folders, named in orange
  (addIgnored in git-diff.js); change-tree rows indent in whole steps
2026-10-06 16:22:53 +07:00
nntrivi2001 7ca1134c3d test(DiffEditor): wait for the split view's revert column, drawn on a measure pass 2026-10-06 15:43:47 +07:00
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00
nntrivi2001 f0500dc592 feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
2026-10-06 12:36:06 +07:00
nntrivi2001 6c605849d8 feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
2026-10-06 11:31:26 +07:00
314 changed files with 23444 additions and 4142 deletions
-20
View File
@@ -1,20 +0,0 @@
---
name: repo-onboarding
description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning.
---
# Repo Onboarding Skill
## Workflow
- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`.
- Determine target layer: `server/`, `client/`, or docs.
- Identify the minimal file set needed for the task.
- Select verification commands before editing.
## Verification defaults
- Backend: `npm run test:server`
- Frontend: `npm run test:client`
## References
- `references/module-map.md`
- `references/verification-map.md`
@@ -1,4 +0,0 @@
interface:
display_name: "Repo Onboarding"
short_description: "Map architecture, ownership, and verification strategy before coding."
default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task."
@@ -1,8 +0,0 @@
# Module Map
- `server/index.js`: app startup and route mounting.
- `server/routes/*.js`: API contracts and route behavior.
- `server/db.js`: schema and statement layer.
- `server/websocket.js`: live update broadcast path.
- `client/src/pages/`: route-level UI.
- `client/src/components/`: reusable UI primitives.
@@ -1,8 +0,0 @@
# Verification Map
- Backend changes:
- `npm run test:server`
- Frontend changes:
- `npm run test:client`
- Docs-only changes:
- validate command consistency against root `package.json`
-1
View File
@@ -24,5 +24,4 @@ This directory contains all project-scoped Codex extensions:
## Included skills ## Included skills
- `repo-onboarding` — architecture discovery and verification selection
- `release-guard` — release readiness checks - `release-guard` — release readiness checks
-20
View File
@@ -1,20 +0,0 @@
---
name: repo-onboarding
description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning.
---
# Repo Onboarding Skill
## Workflow
- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`.
- Determine target layer: `server/`, `client/`, or docs.
- Identify the minimal file set needed for the task.
- Select verification commands before editing.
## Verification defaults
- Backend: `npm run test:server`
- Frontend: `npm run test:client`
## References
- `references/module-map.md`
- `references/verification-map.md`
@@ -1,4 +0,0 @@
interface:
display_name: "Repo Onboarding"
short_description: "Map architecture, ownership, and verification strategy before coding."
default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task."
@@ -1,8 +0,0 @@
# Module Map
- `server/index.js`: app startup and route mounting.
- `server/routes/*.js`: API contracts and route behavior.
- `server/db.js`: schema and statement layer.
- `server/websocket.js`: live update broadcast path.
- `client/src/pages/`: route-level UI.
- `client/src/components/`: reusable UI primitives.
@@ -1,8 +0,0 @@
# Verification Map
- Backend changes:
- `npm run test:server`
- Frontend changes:
- `npm run test:client`
- Docs-only changes:
- validate command consistency against root `package.json`
+14
View File
@@ -131,6 +131,20 @@
# liveness/stale sweeps (which skip them). (default: 600000 = 10min). # liveness/stale sweeps (which skip them). (default: 600000 = 10min).
# DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000 # DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000
# ── Privacy and push ingest (Settings → Data) ────────────────────────────────
# All three are normally saved from Settings into the settings file; set here,
# they are pinned and the Settings page shows them read-only.
# Redact secrets (API keys, tokens, bearer headers, private keys, password=…)
# from stored event payloads and summaries: 1 = on (default: off).
# AGENTHUB_REDACT_SECRETS=1
# Folders whose hooks are dropped whole (and skipped by the history import and
# the Codex reader) — absolute paths, ':'-separated (';' on Windows); a folder
# and everything under it.
# AGENTHUB_IGNORE_CWD=/home/you/clients/secret:/home/you/private
# SHA-256 (hex) of the token POST /api/hooks/ingest-batch accepts. Unset = push
# ingest off. Settings → Data → Push ingest makes the token and stores only this.
# AGENTHUB_PUSH_TOKEN_SHA256=
# ── Lanes (durable parallel-work units and managed git worktrees) ──────────── # ── Lanes (durable parallel-work units and managed git worktrees) ────────────
# Directory dashboard-managed worktrees are provisioned under. It is also the # Directory dashboard-managed worktrees are provisioned under. It is also the
# boundary safety check 2 resolves every destructive lane path against: nothing # boundary safety check 2 resolves every destructive lane path against: nothing
+3
View File
@@ -78,3 +78,6 @@ __pycache__/
# JetBrains IDE config — machine-specific, never shared # JetBrains IDE config — machine-specific, never shared
.idea/ .idea/
# Prometheus bearer token for docker-compose.monitoring.yml (docs/MONITORING.md)
monitoring/prometheus/dashboard-token
+71 -8
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# #
# Pre-commit: auto-format staged files, then run the full test suite. # Pre-commit: auto-format staged files, then run the tests the commit needs —
# The commit is ABORTED unless every test passes. # both full suites for a big or risky commit, only the related tests for a
# small one, none for docs alone (see step 2). The commit is ABORTED unless
# every test it runs passes.
# #
# Activated via `core.hooksPath=.husky` (set by the package.json "prepare" # Activated via `core.hooksPath=.husky` (set by the package.json "prepare"
# script on `npm install`). No husky/lint-staged runtime required. # script on `npm install`). No husky/lint-staged runtime required.
@@ -44,13 +46,13 @@ fi
# is deterministic: it fails both runs and still blocks the commit. This keeps # is deterministic: it fails both runs and still blocks the commit. This keeps
# the gate strict without making commits a dice roll on machine load. # the gate strict without making commits a dice roll on machine load.
run_suite() { run_suite() {
# $1 = human label, $2 = npm script # $1 = human label, $2 = shell command
echo "🧪 Running $1 tests..." echo "🧪 Running $1 tests..."
if npm run "$2"; then if sh -c "$2"; then
return 0 return 0
fi fi
echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..." echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..."
if npm run "$2"; then if sh -c "$2"; then
echo "✅ $1 suite passed on retry — treating the first failure as a load flake." echo "✅ $1 suite passed on retry — treating the first failure as a load flake."
echo " If this keeps happening, the flaky test above deserves a real fix." echo " If this keeps happening, the flaky test above deserves a real fix."
return 0 return 0
@@ -59,7 +61,68 @@ run_suite() {
return 1 return 1
} }
run_suite backend test:server # The frontend suite needs NODE_ENV=test: a shell exporting
run_suite frontend test:client # NODE_ENV=production gets React's production build, where act() throws.
CLIENT_FULL="NODE_ENV=test npm run test:client"
echo "✅ Formatting applied and all tests passed — proceeding with commit." # How much to run. A small commit runs only the tests that touch what it
# stages; a big or risky one runs both full suites, as does
# AGENTHUB_FULL_TESTS=1. Docs alone (*.md, docs/) run nothing.
# client/ files → `vitest related`: vitest's own import graph.
# anything else → every server/__tests__ file that names the file's module
# (a string match: wide on purpose, `db` reaches most).
# ponytail: name matching misses a test that reaches a module only through
# another one; the full run before a release still catches that.
SMALL_MAX=10
CODE="$(git diff --cached --name-only --diff-filter=ACMRD | grep -vE '\.md$|^docs/' || true)"
COUNT=$(printf '%s\n' "$CODE" | grep -c . || true)
FULL=0
[ "${AGENTHUB_FULL_TESTS:-0}" = "1" ] && FULL=1
[ "$COUNT" -gt "$SMALL_MAX" ] && FULL=1
# A dependency, build/test config, this hook or a deleted file can break
# anything, whatever imports it.
if printf '%s\n' "$CODE" | grep -qE '(^|/)package(-lock)?\.json$|(^|/)(vite|vitest|tailwind|postcss|tsconfig)[^/]*$|^\.husky/|^scripts/test-server\.js$'; then
FULL=1
fi
if git diff --cached --name-only --diff-filter=D | grep -vE '\.md$|^docs/' | grep -q .; then
FULL=1
fi
if [ "$COUNT" -eq 0 ]; then
echo "📝 Docs only — no tests to run."
elif [ "$FULL" = "1" ]; then
echo "🧪 Full run ($COUNT code files staged)."
run_suite backend "npm run test:server"
run_suite frontend "$CLIENT_FULL"
else
echo "🧪 Small commit ($COUNT code files) — running the related tests only."
OTHER="$(printf '%s\n' "$CODE" | grep -v '^client/' || true)"
CLIENT="$(printf '%s\n' "$CODE" | grep '^client/' | sed 's|^client/||' || true)"
if [ -n "$OTHER" ]; then
RELATED="$(
printf '%s\n' "$OTHER" | while IFS= read -r f; do
case "$f" in
server/__tests__/*.test.js) echo "$f" ;;
*)
name="$(basename "$f")"
name="${name%.*}"
grep -lF -- "$name" server/__tests__/*.test.js 2>/dev/null || true
;;
esac
done | sort -u | tr '\n' ' '
)"
if [ -n "$RELATED" ]; then
run_suite "backend ($(echo $RELATED | wc -w) files)" "node scripts/test-server.js $RELATED"
else
echo "⏭️ No server test names a staged file — backend skipped."
fi
fi
if [ -n "$CLIENT" ]; then
LIST="$(printf '%s\n' "$CLIENT" | tr '\n' ' ')"
run_suite "frontend (related)" "cd client && NODE_ENV=test NODE_OPTIONS=--no-experimental-webstorage npx vitest related --run --passWithNoTests $LIST"
fi
fi
echo "✅ Formatting applied and the tests passed — proceeding with commit."
+32 -7
View File
@@ -325,8 +325,9 @@ graph TD
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning | | `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` | | `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks | | `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately | | `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Four rule types: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing | | `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` | | `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes | | `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint | | `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
@@ -351,8 +352,8 @@ graph TD
| `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers | | `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers |
| `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle | | `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle |
| `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition | | `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition |
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged | | `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) and `?tool=` (an AI tool id; `parseScope` folds both into one predicate over `sessions`) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | | `server/lib/scoped-stats.js` | Scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` or `?tool=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines | | `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
| `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-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-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 |
@@ -367,6 +368,7 @@ graph TD
| `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. | | `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. |
| `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. | | `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. |
| `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/<id>.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. | | `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/<id>.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. |
| `lib/cursor-usage.js` | Cursor sessions (no tokens: Cursor's transcripts carry none) from `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`, every `DASHBOARD_CURSOR_POLL_MS`. See **Cursor Sessions**. |
| `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. | | `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. |
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes | | `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer | | `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
@@ -850,7 +852,7 @@ erDiagram
alert_rules { alert_rules {
TEXT id PK "UUID" TEXT id PK "UUID"
TEXT name "User-facing rule name" TEXT name "User-facing rule name"
TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold" TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold|cost_threshold|budget (no CHECK; validated in lib/alerts.js)"
TEXT config "Type-specific JSON config" TEXT config "Type-specific JSON config"
INTEGER enabled "1|0" INTEGER enabled "1|0"
INTEGER cooldown_seconds "Per-scope dedup window, default 300" INTEGER cooldown_seconds "Per-scope dedup window, default 300"
@@ -1731,6 +1733,29 @@ is ever inferred.
--- ---
## Cursor Sessions
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
`tool = 'cursor'`: id = file name, name = the first `<user_query>` line (60
chars), start = the file's birth time (mtime where there is none), `active`
while it was written in the last two minutes, else `completed` at its last
write. The cwd is the project slug walked back onto the filesystem (every
non-alphanumeric character is a `-` in the slug, so only a path that still
exists resolves); otherwise it stays NULL. `cursor_transcripts` holds the mtime
read, so a session the user deleted stays deleted until Cursor writes to it again.
**No tokens, no model, no cost.** A transcript line is `{role, message:{content}}`
or `{type: "turn_ended"}` — no usage, no model id, no timestamps — so a Cursor
session has no `token_usage` rows. The editor's `state.vscdb` keeps a
`tokenCount` per chat bubble and a display model name (`GPT`, `default`, a BYOK
name); AgentHub does not read it. No Cursor hooks are installed.
---
## Agent Extension Layer ## Agent Extension Layer
The repository includes a triple extension strategy: The repository includes a triple extension strategy:
@@ -2271,8 +2296,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). | | Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. | | Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. | | Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. | | Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. It then polls `status` and reloads once the old server went down and answered again, or as soon as a different `local_version` answers; a verdict of this chain (`finished_at` after the click) that is `ok:false` or `already_current` ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose `/api/health` `version` is not the bundle's (`client/src/lib/newBuildReload.ts`, once per version). |
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. | | Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner — a tab waiting on **Update now** stops waiting and says nothing new was pulled. |
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. | | Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. | | Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. | | Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
+15 -8
View File
File diff suppressed because one or more lines are too long
+8 -7
View File
@@ -31,9 +31,9 @@ dashboard, run it from source, or stay on Node 20.
| npm | 9+ | Comes with Node.js | | npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration | | Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) | | Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x (3.7+ for pane scrollbars) | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) | | **tmux** | 3.x (3.7+ for pane scrollbars) | **Required for the run console** — the browser terminal, `agenthub workspace shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` | | Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` | | GitHub CLI (`gh`) | Any | Optional — only for `agenthub workspace ci status` |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) | | Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
--- ---
@@ -42,7 +42,7 @@ dashboard, run it from source, or stay on Node 20.
Node and npm are not enough: `git` backs every lane operation, and **`tmux` Node and npm are not enough: `git` backs every lane operation, and **`tmux`
backs the run console**. The dashboard shells out to the `tmux` binary and has backs the run console**. The dashboard shells out to the `tmux` binary and has
no bundled fallback, so without it **Start Run** and `agenthub lanes shell` no bundled fallback, so without it **Start Run** and `agenthub workspace shell`
fail while everything else keeps working. fail while everything else keeps working.
```bash ```bash
@@ -93,7 +93,7 @@ session explicitly). You can attach to the
exact same pane from a normal terminal: exact same pane from a normal terminal:
```bash ```bash
agenthub lanes shell # from inside the lane's directory agenthub workspace shell # from inside the lane's directory
tmux attach -t agenthub-lane-7 # or by name tmux attach -t agenthub-lane-7 # or by name
tmux ls # every dashboard session is prefixed agenthub-lane- tmux ls # every dashboard session is prefixed agenthub-lane-
``` ```
@@ -117,9 +117,10 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher `node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary. `brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs - **Starts at login once you turn it on (LaunchAgent).** Off by default; with
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
service install`), so the dashboard comes back after every login with no installs `~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout:
the same switch, or `agenthub service install`), so the dashboard comes back after every login with no
Claude Code session, and launchd restarts it if it dies. Lane panes survive Claude Code session, and launchd restarts it if it dies. Lane panes survive
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`. a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
A LaunchAgent needs a login at the Mac itself; over SSH only, or when A LaunchAgent needs a login at the Mac itself; over SSH only, or when
+42 -18
View File
@@ -41,7 +41,7 @@ Internal build — all rights reserved.
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real - **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub lanes shell`. Which CLI it starts is the workspace's own terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
setting. **This is the part that needs `tmux` installed** — see setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites). [Prerequisites](#prerequisites).
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind` - **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
@@ -52,7 +52,7 @@ Internal build — all rights reserved.
either uncommitted work or the whole branch against its base. There is no 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. merge/rebase/reset button by design: recovery from those needs a terminal.
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own - **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per workspace (`agenthub lanes up|down|logs`). slot, ports and services per workspace (`agenthub workspace up|down|logs`).
**Configure** **Configure**
@@ -86,6 +86,12 @@ Internal build — all rights reserved.
notification type can pick its own. notification type can pick its own.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and - **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean. a UI in English, Vietnamese, Chinese and Korean.
- **Outside the dashboard, all opt-in:** a read-only MCP server
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
Claude Code statusline with session and today's cost
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
Prometheus rules + Grafana boards for `/api/metrics`
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
## Prerequisites ## Prerequisites
@@ -94,10 +100,10 @@ Internal build — all rights reserved.
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** | | **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node | | **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) | | **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane | | **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side | | **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` workspaces | | **Codex CLI** | any | Optional — only for `codex` workspaces |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` | | **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) | | **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node **25** is the one version to avoid: it breaks 6 server tests through a Node **25** is the one version to avoid: it breaks 6 server tests through a
@@ -108,7 +114,7 @@ v26.8.1).
### Installing tmux ### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub lanes shell` fail without it. Everything else **Start Run** and `agenthub workspace shell` fail without it. Everything else
(sessions, analytics, workspaces, stage detection, the CLI) works fine. (sessions, analytics, workspaces, stage detection, the CLI) works fine.
```bash ```bash
@@ -134,7 +140,7 @@ curl -s localhost:4820/api/run/tmux # {"available":true}
``` ```
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
shell`) are two clients on the same pane — they stay in sync, and killing the shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run. dashboard never kills the run.
@@ -197,7 +203,10 @@ agenthub service uninstall # disable and remove it
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. Plugin installs get the same persistence automatically — see once yourself. The same is a switch in the dashboard — Settings → About →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`. [Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
## The CLI ## The CLI
@@ -209,9 +218,9 @@ agenthub status # is the dashboard up
agenthub start # start it in the background and wait for healthy agenthub start # start it in the background and wait for healthy
agenthub service install # auto-start on boot via systemd --user (Linux) agenthub service install # auto-start on boot via systemd --user (Linux)
agenthub sessions # recent sessions agenthub sessions # recent sessions
agenthub lanes # lanes with stage and progress agenthub workspaces # workspaces with stage and progress
agenthub lanes pipeline # this lane's pipeline template, or switch it agenthub workspace pipeline # this lane's pipeline template, or switch it
agenthub lanes node <id> # run ONE pipeline node's own command (no id: list them) agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
agenthub stage <name> # declare the current lane's stage agenthub stage <name> # declare the current lane's stage
agenthub tail # live event feed agenthub tail # live event feed
``` ```
@@ -231,8 +240,8 @@ A workspace is a working directory the dashboard watches. Two kinds:
for you to delete yourself. for you to delete yourself.
```bash ```bash
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
``` ```
Every workspace also carries the CLI that drives it — `claude` (the default) or Every workspace also carries the CLI that drives it — `claude` (the default) or
@@ -268,6 +277,13 @@ for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity workspaces carry no pipeline either. hooks. Antigravity workspaces carry no pipeline either.
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
read from Cursor's agent transcripts
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
no token counts or model ids there, so a Cursor session shows no tokens and no
cost.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no **Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then [`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
@@ -279,7 +295,7 @@ events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace
move backwards between work sessions. Detection never writes the declared stage, move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done. and an inferred node never renders as done.
A workspace's pipeline template can be switched after creation — `agenthub lanes A workspace's pipeline template can be switched after creation — `agenthub workspace
pipeline <template>` from the terminal, or the pipeline-template picker next to pipeline <template>` from the terminal, or the pipeline-template picker next to
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
current declared stage against the new template's nodes and warn if it no current declared stage against the new template's nodes and warn if it no
@@ -291,10 +307,10 @@ declarations plus shell hooks the dashboard calls. Each workspace gets a slot, a
ports and per-workspace directories derive from it: ports and per-workspace directories derive from it:
```bash ```bash
agenthub lanes up # boot the stack of the lane owning this directory agenthub workspace up # boot the stack of the lane owning this directory
agenthub lanes runtime # slot, ports, service health agenthub workspace runtime # slot, ports, service health
agenthub lanes logs api # tail a service log agenthub workspace logs api # tail a service log
agenthub lanes down agenthub workspace down
``` ```
Services are fully detached, so restarting the dashboard never stops a running Services are fully detached, so restarting the dashboard never stops a running
@@ -314,7 +330,13 @@ npm run test:server # node:test
npm run test:client # Vitest npm run test:client # Vitest
``` ```
Both must be green before a commit; the pre-commit hook runs them plus Prettier. Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
runs Prettier, then the tests the commit needs: both full suites for a big or
risky commit (over 10 code files, a dependency, build/test config or the hook
itself, a deleted file), only the related tests for a small one (`vitest
related` for `client/`, the server test files that name each other staged
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
both full suites.
## Layout ## Layout
@@ -323,6 +345,8 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries | | `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard | | `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI | | `bin/agenthub.js` | CLI |
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
| `docs/` | Architecture, API, workspaces, database | | `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard | | `plugins/` | Claude Code plugins shipped with the dashboard |
+27 -19
View File
@@ -42,10 +42,10 @@ Internal build — all rights reserved.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY - **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
thật, render bằng xterm.js) gắn vào thư mục của workspace — đúng cái TUI bạn thấy ở thật, render bằng xterm.js) gắn vào thư mục của workspace — đúng cái TUI bạn thấy ở
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây `agenthub workspace shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống). chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
- **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi - **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi
workspace được cấp slot, port và service riêng (`agenthub lanes up|down|logs`). workspace được cấp slot, port và service riêng (`agenthub workspace up|down|logs`).
**Cấu hình** **Cấu hình**
@@ -76,10 +76,10 @@ Internal build — all rights reserved.
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** | | **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node | | **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) | | **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống | | **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring | | **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho workspace dùng `codex` | | **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho workspace dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` | | **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub workspace ci status` |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) | | **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
@@ -89,7 +89,7 @@ cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
### Cài tmux ### Cài tmux
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session, thì **Start Run** và `agenthub workspace shell` sẽ lỗi. Phần còn lại (session,
analytics, workspace, stage detection, CLI) vẫn chạy bình thường. analytics, workspace, stage detection, CLI) vẫn chạy bình thường.
```bash ```bash
@@ -115,7 +115,7 @@ curl -s localhost:4820/api/run/tmux # {"available":true}
``` ```
Mỗi workspace chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal Mỗi workspace chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub workspace
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`. dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
@@ -176,7 +176,10 @@ agenthub service uninstall # disable và gỡ bỏ
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi `agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần. này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
Bản cài qua plugin cũng được tự khởi động tương tự — xem Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
bật — xem
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong [Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
`docs/PLUGINS.md`. `docs/PLUGINS.md`.
@@ -189,9 +192,9 @@ agenthub status # dashboard có đang chạy không
agenthub start # start ngầm (background) và chờ tới khi healthy agenthub start # start ngầm (background) và chờ tới khi healthy
agenthub service install # tự khởi động khi boot máy qua systemd --user (Linux) agenthub service install # tự khởi động khi boot máy qua systemd --user (Linux)
agenthub sessions # session gần đây agenthub sessions # session gần đây
agenthub lanes # danh sách lane kèm stage và progress agenthub workspaces # danh sách workspace kèm stage và progress
agenthub lanes pipeline # pipeline template của lane này, hoặc đổi nó agenthub workspace pipeline # pipeline template của lane này, hoặc đổi nó
agenthub lanes node <id> # chạy riêng command của MỘT node (không có id: liệt kê) agenthub workspace node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
agenthub stage <name> # khai báo stage hiện tại của lane agenthub stage <name> # khai báo stage hiện tại của lane
agenthub tail # xem live event feed agenthub tail # xem live event feed
``` ```
@@ -210,8 +213,8 @@ Workspace là một working directory mà dashboard theo dõi. Hai loại:
gọi phải echo lại. gọi phải echo lại.
```bash ```bash
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
``` ```
Mỗi workspace còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` — Mỗi workspace còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
@@ -236,7 +239,7 @@ từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút),
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
báo, và node suy luận không bao giờ render thành `done`. báo, và node suy luận không bao giờ render thành `done`.
Pipeline template của workspace có thể đổi sau khi tạo — `agenthub lanes Pipeline template của workspace có thể đổi sau khi tạo — `agenthub workspace
pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template
cạnh tiêu đề workspace trong panel chi tiết của trang Workspace. Cả hai đều cạnh tiêu đề workspace trong panel chi tiết của trang Workspace. Cả hai đều
re-resolve lại stage đã khai báo hiện tại của workspace theo node-list của template re-resolve lại stage đã khai báo hiện tại của workspace theo node-list của template
@@ -248,10 +251,10 @@ workspace, khi repository của nó khai báo một profile tại `<repo>/.agent
được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó: được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó:
```bash ```bash
agenthub lanes up # boot stack của lane sở hữu thư mục hiện tại agenthub workspace up # boot stack của lane sở hữu thư mục hiện tại
agenthub lanes runtime # slot, port, tình trạng service agenthub workspace runtime # slot, port, tình trạng service
agenthub lanes logs api # tail log của một service agenthub workspace logs api # tail log của một service
agenthub lanes down agenthub workspace down
``` ```
Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng
@@ -269,8 +272,13 @@ npm run test:server # node:test
npm run test:client # Vitest npm run test:client # Vitest
``` ```
Cả hai phải xanh (pass) trước khi commit; pre-commit hook chạy chúng cộng Cả hai phải xanh (pass) trước khi commit. Pre-commit hook (`.husky/pre-commit`)
thêm Prettier. chạy Prettier, rồi chạy những test mà commit cần: cả hai bộ đầy đủ cho commit
lớn hoặc rủi ro (hơn 10 file code, đổi dependency, cấu hình build/test hay
chính hook, có xoá file), chỉ test liên quan cho commit nhỏ (`vitest related`
cho `client/`, các file test server có nhắc tới module được stage), và không
chạy test nào nếu chỉ sửa tài liệu. `AGENTHUB_FULL_TESTS=1 git commit …` buộc
chạy cả hai bộ đầy đủ.
## Cấu trúc thư mục ## Cấu trúc thư mục
File diff suppressed because one or more lines are too long
+150 -57
View File
@@ -141,21 +141,10 @@ function termWidth() {
/** /**
* Resolve the dashboard base URL. Env override first (matches the hook * Resolve the dashboard base URL. Env override first (matches the hook
* handler's contract), then the live-server discovery file, then the default * handler's contract), then the live-server discovery file, then the default
* port. The discovery module is best-effort and never throws. * port — shared with the MCP server and the statusline (`dashboard-url.js`).
*/ */
function baseUrl() { function baseUrl() {
const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT; return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
if (envPort) return `http://127.0.0.1:${envPort}`;
try {
const { resolveDashboardPort } = require(
path.join(REPO_ROOT, "server", "lib", "server-info.js")
);
const port = resolveDashboardPort();
if (port) return `http://127.0.0.1:${port}`;
} catch {
/* discovery unavailable — fall through to the default */
}
return "http://127.0.0.1:4820";
} }
/** Thrown when the dashboard server does not answer — the dispatcher decides /** Thrown when the dashboard server does not answer — the dispatcher decides
@@ -1037,6 +1026,8 @@ async function cmdRemoteSources(flags, positional) {
ssh_port: flags.port != null ? Number(flags.port) : null, ssh_port: flags.port != null ? Number(flags.port) : null,
identity_file: flags.identity != null ? String(flags.identity) : null, identity_file: flags.identity != null ? String(flags.identity) : null,
remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null, remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null,
sync_codex: !!flags.codex,
remote_codex_home: flags["codex-home"] != null ? String(flags["codex-home"]) : null,
enabled: flags.disabled ? false : true, enabled: flags.disabled ? false : true,
}; };
const r = await post("/api/remote-sources", body); const r = await post("/api/remote-sources", body);
@@ -1291,7 +1282,7 @@ async function cmdDoctor() {
console.log( console.log(
available available
? `${c.green("✔")} tmux available to the server` ? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` + : `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server` `Install it (sudo apt install tmux / brew install tmux), then restart the server`
); );
} catch { } catch {
@@ -1616,7 +1607,7 @@ async function cmdLanesAdd(args) {
if (repo) { if (repo) {
if (cwd || pipeline) { if (cwd || pipeline) {
console.error( console.error(
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]" "usage: agenthub workspace add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -1653,7 +1644,7 @@ async function cmdLanesAdd(args) {
} }
if (!cwd || !title) { if (!cwd || !title) {
console.error( console.error(
"usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]" "usage: agenthub workspace add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -1704,7 +1695,7 @@ function cmdLanesProfileInit(args) {
const repo = args.find((arg) => !arg.startsWith("--")); const repo = args.find((arg) => !arg.startsWith("--"));
const force = args.includes("--force"); const force = args.includes("--force");
if (!repo) { if (!repo) {
console.error("usage: agenthub lanes profile init <repo> [--force]"); console.error("usage: agenthub workspace profile init <repo> [--force]");
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -1746,7 +1737,7 @@ function cmdLanesProfileInit(args) {
console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`); console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
for (const todo of result.todos) console.log(` ${todo}`); for (const todo of result.todos) console.log(` ${todo}`);
} }
console.log(`\nNext: agenthub lanes profile check ${repo}`); console.log(`\nNext: agenthub workspace profile check ${repo}`);
} }
/** /**
@@ -1795,7 +1786,7 @@ async function cmdLanesLifecycle(action, args) {
const fields = LANE_PREFLIGHT_FIELDS[action]; const fields = LANE_PREFLIGHT_FIELDS[action];
if (!id) { if (!id) {
const extra = action === "reset" ? " [--keep-db]" : ""; const extra = action === "reset" ? " [--keep-db]" : "";
console.error(`usage: agenthub lanes ${action} <id> [--force]${extra} --yes`); console.error(`usage: agenthub workspace ${action} <id> [--force]${extra} --yes`);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -1834,7 +1825,7 @@ async function cmdLanesLifecycle(action, args) {
preflight.blocked.includes("adopted") preflight.blocked.includes("adopted")
) { ) {
console.error( console.error(
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.` `Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub workspace remove ${id} --yes\` to drop only the dashboard's record of it.`
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -1902,7 +1893,7 @@ async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes"); const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) { if (!lanes.length) {
console.log( console.log(
"no workspaces yet — create one with: agenthub lanes add --cwd <path> --title <text>" "no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
); );
return; return;
} }
@@ -1957,7 +1948,7 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined; return i > -1 ? args[i + 1] : undefined;
}; };
const explicit = flagValue("lane"); const explicit = flagValue("lane") || flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args }; if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) }; if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
@@ -1981,7 +1972,9 @@ async function resolveLaneArg(args) {
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`)) .filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length); .sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) { if (!owning.length) {
console.error(`no workspace owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`); console.error(
`no workspace owns ${cwd} — create one with: agenthub workspace add --cwd ${cwd}`
);
process.exitCode = 1; process.exitCode = 1;
return null; return null;
} }
@@ -2002,7 +1995,7 @@ async function resolveLaneArg(args) {
console.error( console.error(
`${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates `${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude") .map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates .join("|")}> or --workspace <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`) .map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}` .join(", ")}`
); );
@@ -2020,7 +2013,7 @@ function printRuntime(runtime) {
return; return;
} }
if (!runtime.provisioned) { if (!runtime.provisioned) {
console.log("profile found, runtime not provisioned yet — run: agenthub lanes up"); console.log("profile found, runtime not provisioned yet — run: agenthub workspace up");
return; return;
} }
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`); console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
@@ -2121,7 +2114,7 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "logs") { if (sub === "logs") {
const svc = laneArgs.find((arg) => !arg.startsWith("--")); const svc = laneArgs.find((arg) => !arg.startsWith("--"));
if (!svc) { if (!svc) {
console.error("usage: agenthub lanes logs [<id>] <service> [--tail bytes]"); console.error("usage: agenthub workspace logs [<id>] <service> [--tail bytes]");
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -2140,7 +2133,7 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "hook") { if (sub === "hook") {
const name = laneArgs[0]; const name = laneArgs[0];
if (!name || name.startsWith("--")) { if (!name || name.startsWith("--")) {
console.error("usage: agenthub lanes hook [<id>] <name> [args…]"); console.error("usage: agenthub workspace hook [<id>] <name> [args…]");
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -2155,7 +2148,7 @@ async function cmdLanesRuntime(sub, args) {
return; return;
} }
console.log( console.log(
`workspace #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}` `workspace #${laneId} running hook ${name} — follow it with: agenthub workspace logs ${laneId} ${name}`
); );
} }
@@ -2191,7 +2184,7 @@ async function cmdLanesRuntime(sub, args) {
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`); console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit"); console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error( console.error(
` then: agenthub lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}` ` then: agenthub workspace sync-base --continue ${branch ? branch + " " : ""}${laneId}`
); );
process.exitCode = 4; process.exitCode = 4;
return; return;
@@ -2437,7 +2430,7 @@ async function cmdLanesMcpSync(args) {
async function cmdLanesIntegration(args) { async function cmdLanesIntegration(args) {
const name = args.find((arg) => !arg.startsWith("--")); const name = args.find((arg) => !arg.startsWith("--"));
if (!name) { if (!name) {
console.error("usage: agenthub lanes integration <name> [<id>]"); console.error("usage: agenthub workspace integration <name> [<id>]");
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -2456,13 +2449,13 @@ async function cmdLanesIntegration(args) {
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */ * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
async function cmdLanesCi(args) { async function cmdLanesCi(args) {
if (args[0] !== "status") { if (args[0] !== "status") {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]"); console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
const ref = args[1]; const ref = args[1];
if (!ref) { if (!ref) {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]"); console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -2538,7 +2531,7 @@ async function cmdLanesNode(args) {
if (!key) { if (!key) {
listNodes(); listNodes();
console.error(`\nusage: agenthub lanes node <node-id> [extra args…] [<id>] [--print]`); console.error(`\nusage: agenthub workspace node <node-id> [extra args…] [<id>] [--print]`);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -2683,7 +2676,7 @@ async function cmdStage(args) {
const stage = args[0]; const stage = args[0];
if (!stage || stage.startsWith("--")) { if (!stage || stage.startsWith("--")) {
console.error( console.error(
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]" "usage: agenthub stage <stage> [--workspace <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -2795,7 +2788,7 @@ const COMMAND_GROUPS = [
[ [
"remote-sources add", "remote-sources add",
"", "",
"--label X --host user@host [--port N --identity path --remote-home path]", "--label X --host user@host [--port N --identity path --remote-home path --codex --codex-home path]",
], ],
["remote-sources test <id>", "", "Probe SSH connectivity to a source"], ["remote-sources test <id>", "", "Probe SSH connectivity to a source"],
["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"], ["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"],
@@ -2805,82 +2798,86 @@ const COMMAND_GROUPS = [
[ [
"Workspaces", "Workspaces",
[ [
["lanes", "", "List workspaces with stage, liveness and progress"], ["workspaces", "", "List workspaces with stage, liveness and progress"],
[ [
"lanes add", "workspace add",
"--cwd <path> --title <text> [--tool <id>]", "--cwd <path> --title <text> [--tool <id>]",
"Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)", "Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)",
], ],
[ [
"lanes add", "workspace add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]", "--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree workspace", "Provision a managed worktree workspace",
], ],
[ [
"lanes profile init", "workspace profile init",
"<repo> [--force]", "<repo> [--force]",
"Detect a Node.js project and scaffold .agenthub/profile/", "Detect a Node.js project and scaffold .agenthub/profile/",
], ],
[ [
"lanes profile check", "workspace profile check",
"[<path>]", "[<path>]",
"Validate a profile (path defaults to cwd, not a workspace id)", "Validate a profile (path defaults to cwd, not a workspace id)",
], ],
[ [
"lanes pipeline", "workspace pipeline",
"[<template-id>] [<id>]", "[<template-id>] [<id>]",
"Show, or switch, which pipeline template a workspace renders against", "Show, or switch, which pipeline template a workspace renders against",
], ],
[ [
"lanes node", "workspace node",
"[<node-id>] [extra…] [<id>] [--print]", "[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)", "Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)",
], ],
[ [
"lanes shell", "workspace shell",
"[<id>]", "[<id>]",
"Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run", "Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run",
], ],
[ [
"lanes reset|remove|purge", "workspace reset|remove|purge",
"<id> [--force] [--keep-db] --yes", "<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)", "Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
], ],
[ [
"lanes up|down", "workspace up|down",
"[<id>] [--no-build] [--qc]", "[<id>] [--no-build] [--qc]",
"Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)", "Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)",
], ],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"], ["workspace runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the workspace's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[ [
"lanes sync-base", "workspace logs",
"[<id>] <svc> [--tail N]",
"Tail one of the workspace's hook or service logs",
],
["workspace hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"workspace sync-base",
"[<id>] [--check|--continue] [branch]", "[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)", "Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)",
], ],
[ [
"lanes proof-link", "workspace proof-link",
"[<id>]", "[<id>]",
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)", "Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
], ],
[ [
"lanes mcp sync", "workspace mcp sync",
"[<id>]", "[<id>]",
"Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles", "Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles",
], ],
[ [
"lanes integration", "workspace integration",
"<name> [<id>]", "<name> [<id>]",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1", "Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1",
], ],
[ [
"lanes ci status", "workspace ci status",
"<branch-or-sha> [<id>]", "<branch-or-sha> [<id>]",
"One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending", "One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending",
], ],
[ [
"lanes gc", "workspace gc",
"[--dry-run]", "[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace", "Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace",
], ],
@@ -2911,6 +2908,22 @@ const COMMAND_GROUPS = [
], ],
], ],
], ],
[
"Integrations",
[
[
"mcp",
"",
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
],
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
[
"statusline install|uninstall",
"[--force]",
"Add/remove AgentHub's statusLine in ~/.claude/settings.json (--force: replace or remove another one)",
],
],
],
[ [
"Administration", "Administration",
[ [
@@ -2944,6 +2957,9 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped. ${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1. Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands ${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background). require it to be running (${c.bold("agenthub start")} brings one up in the background).
Prefer an interactive session? Run ${c.bold("agenthub repl")} for a shell with completion, Prefer an interactive session? Run ${c.bold("agenthub repl")} for a shell with completion,
@@ -3352,10 +3368,18 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry /** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */ * whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) { function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
// so `help lanes` finds the `workspace …` rows and vice versa.
const canon = (words) => {
const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
};
const want = canon(token);
const matches = []; const matches = [];
for (const [group, items] of COMMAND_GROUPS) { for (const [group, items] of COMMAND_GROUPS) {
for (const [name, args, desc] of items) { for (const [name, args, desc] of items) {
if (name === token || name.startsWith(token + " ")) matches.push([group, name, args, desc]); const have = canon(name);
if (have === want || have.startsWith(want + " ")) matches.push([group, name, args, desc]);
} }
} }
if (!matches.length) return false; if (!matches.length) return false;
@@ -3606,6 +3630,26 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller // prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal. // decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word.
* `workspace` is the name to use; `lanes` (the handler's own name) and
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
function warnOldNames(argv) {
const old = [];
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
if (old.length) {
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
}
}
/** All top-level command tokens, derived from COMMAND_GROUPS (first word of /** All top-level command tokens, derived from COMMAND_GROUPS (first word of
* each invocation) plus the always-available `help`. Used by the REPL * each invocation) plus the always-available `help`. Used by the REPL
* completer and unknown-command detection so they never drift from help. */ * completer and unknown-command detection so they never drift from help. */
@@ -3615,6 +3659,8 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]); for (const [name] of items) set.add(name.split(" ")[0]);
} }
set.add("help"); set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set]; return [...set];
})(); })();
@@ -3625,14 +3671,50 @@ const SUBCOMMANDS = {
pricing: ["set", "delete", "reset"], pricing: ["set", "delete", "reset"],
webhooks: ["test"], webhooks: ["test"],
import: ["rescan", "path"], import: ["rescan", "path"],
statusline: ["install", "uninstall"],
}; };
/** `agenthub statusline install|uninstall [--force]` — the user's settings.json. */
function cmdStatusline(sub, flags) {
const { install, uninstall } = require(path.join(REPO_ROOT, "statusline", "install.js"));
const { getSettingsPath } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
const file = getSettingsPath();
const force = Boolean(flags.force);
const r = sub === "install" ? install(file, { force }) : uninstall(file, { force });
const prev = r.previous ? JSON.stringify(r.previous) : "";
switch (r.status) {
case "installed":
console.log(`${c.green("✔")} statusLine set in ${file}: ${r.command}`);
if (prev) console.log(c.dim(` replaced ${prev} (backup beside the file)`));
console.log(c.dim(" Claude Code picks it up on the next prompt."));
return;
case "unchanged":
console.log(`statusLine already set in ${file}: ${r.command}`);
return;
case "removed":
console.log(`${c.green("✔")} statusLine removed from ${file} (backup beside the file)`);
return;
case "absent":
console.log(`No statusLine in ${file}.`);
return;
case "foreign":
console.error(c.red(`✖ ${file} has a statusLine that is not AgentHub's: ${prev}`));
console.error(
c.dim(
` Re-run with --force to ${sub === "install" ? "replace" : "remove"} it (a backup is kept).`
)
);
process.exitCode = 1;
}
}
/** Run one parsed command. Returns the handler's promise; may throw /** Run one parsed command. Returns the handler's promise; may throw
* ServerDownError (for the caller's offline routing) or call process.exit * ServerDownError (for the caller's offline routing) or call process.exit
* for usage/validation failures. `cmd === "repl"` is handled by the entry * for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */ * points, not here, to avoid recursive REPLs. */
async function runCommand(argv) { async function runCommand(argv) {
const [cmd, ...rest] = argv; warnOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest); const { flags, positional } = parseArgs(rest);
switch (cmd) { switch (cmd) {
case "health": case "health":
@@ -3702,7 +3784,7 @@ async function runCommand(argv) {
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2)); if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2)); if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
console.error( console.error(
"usage: agenthub lanes profile init <repo> [--force] | agenthub lanes profile check [<path>]" "usage: agenthub workspace profile init <repo> [--force] | agenthub workspace profile check [<path>]"
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -3760,6 +3842,16 @@ async function runCommand(argv) {
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
case "mcp":
return require(path.join(REPO_ROOT, "mcp", "server.js")).main();
case "statusline":
if (rest[0] === "install" || rest[0] === "uninstall") return cmdStatusline(rest[0], flags);
if (rest[0]) {
console.error("usage: agenthub statusline [install|uninstall [--force]]");
process.exitCode = 1;
return;
}
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
case "open": case "open":
return cmdOpen(); return cmdOpen();
case "version": case "version":
@@ -3786,6 +3878,7 @@ async function runCommand(argv) {
* an embedding loop can stay alive. * an embedding loop can stay alive.
*/ */
async function handleServerDown(argv) { async function handleServerDown(argv) {
argv = canonicalArgv(argv);
const cmd = argv[0]; const cmd = argv[0];
const { flags, positional } = parseArgs(argv.slice(1)); const { flags, positional } = parseArgs(argv.slice(1));
const handler = OFFLINE_HANDLERS[cmd]; const handler = OFFLINE_HANDLERS[cmd];
+10 -4
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features - **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **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/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) 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|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — 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 - **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) 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|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — 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. **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.
@@ -199,13 +199,17 @@ client/
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing │ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning) │ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card │ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card │ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files) │ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │ │ │
│ ├── pages/ # Route pages │ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs │ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID │ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) │ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates │ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow │ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
│ │ ├── Settings.tsx │ │ ├── Settings.tsx
@@ -255,7 +259,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline] Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log] Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics] Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions] Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
App --> Layout App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings Layout --> Dashboard & Sessions & Detail & Feed & Settings
@@ -501,6 +505,8 @@ function App() {
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} /> <Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} /> <Route path="sessions/:id" element={<SessionDetail />} />
{/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
{/* legacy path - the activity feed is now a tab on /sessions */} {/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} /> <Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} /> <Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
+112
View File
@@ -8,6 +8,10 @@
"name": "agenthub-client", "name": "agenthub-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
@@ -410,6 +414,66 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.13",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1052,6 +1116,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2580,6 +2674,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-tree": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -5702,6 +5802,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/style-to-js": { "node_modules/style-to-js": {
"version": "1.1.21", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -6402,6 +6508,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+4
View File
@@ -11,6 +11,10 @@
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest" "test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
+24
View File
@@ -70,6 +70,8 @@ import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket"; import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications"; import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus"; import { eventBus } from "./lib/eventBus";
import { copyOnCtrlShiftC } from "./lib/keys";
import { reloadOnNewBuild } from "./lib/newBuildReload";
import type { WSMessage } from "./lib/types"; import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first // The heavy pages (xterm, the diff viewer, the settings editors) load on first
@@ -81,6 +83,9 @@ const SettingsHub = lazy(() =>
const SessionDetail = lazy(() => const SessionDetail = lazy(() =>
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })) import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
); );
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
function PageFallback() { function PageFallback() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -128,6 +133,16 @@ export default function App() {
return () => window.removeEventListener("vite:preloadError", onPreloadError); return () => window.removeEventListener("vite:preloadError", onPreloadError);
}, []); }, []);
// The server came back as a newer version (any tab, any way it was updated):
// load its UI build instead of leaving the user to press F5.
useEffect(() => reloadOnNewBuild(), []);
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
useEffect(() => {
window.addEventListener("keydown", copyOnCtrlShiftC, true);
return () => window.removeEventListener("keydown", copyOnCtrlShiftC, true);
}, []);
return ( return (
<> <>
<SplashScreen /> <SplashScreen />
@@ -151,6 +166,15 @@ export default function App() {
</Page> </Page>
} }
/> />
<Route
path="snapshot/:token"
element={
<Page>
<SnapshotView />
</Page>
}
/>
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} /> <Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route <Route
path="workflows" path="workflows"
+245 -5
View File
@@ -70,6 +70,8 @@ import {
BellOff, BellOff,
Check, Check,
CheckCheck, CheckCheck,
ChevronDown,
Eye,
ListChecks, ListChecks,
Pencil, Pencil,
Plus, Plus,
@@ -87,9 +89,10 @@ import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox"; import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp"; import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format"; import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types"; import { useAiTools } from "../hooks/useAiTools";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -124,6 +127,8 @@ const RULE_TYPES: AlertRuleType[] = [
"inactivity", "inactivity",
"status_duration", "status_duration",
"token_threshold", "token_threshold",
"cost_threshold",
"budget",
]; ];
type TabKey = "rules" | "channels" | "activity"; type TabKey = "rules" | "channels" | "activity";
@@ -139,6 +144,11 @@ interface RuleFormState {
minutes: string; minutes: string;
status: "working" | "waiting"; status: "working" | "waiting";
total_tokens: string; total_tokens: string;
usd: string;
period: "day" | "week" | "month";
limit_usd: string;
thresholds: string;
budget_tool: string;
cooldown_seconds: string; cooldown_seconds: string;
} }
@@ -153,6 +163,11 @@ const EMPTY_FORM: RuleFormState = {
minutes: "10", minutes: "10",
status: "working", status: "working",
total_tokens: "1000000", total_tokens: "1000000",
usd: "5",
period: "month",
limit_usd: "100",
thresholds: "80, 100",
budget_tool: "",
cooldown_seconds: "300", cooldown_seconds: "300",
}; };
@@ -171,12 +186,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
minutes: str(c.minutes, EMPTY_FORM.minutes), minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working", status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens), total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
usd: str(c.usd, EMPTY_FORM.usd),
period: c.period === "day" || c.period === "week" ? c.period : "month",
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
budget_tool: str(c.tool, ""),
cooldown_seconds: String(rule.cooldown_seconds), cooldown_seconds: String(rule.cooldown_seconds),
}; };
} }
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err)); const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
function parseThresholds(text: string): number[] | null {
const parts = text
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const nums = parts.map((p) => Number(p));
if (nums.length === 0 || nums.length > 10) return null;
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
}
function buildConfig(form: RuleFormState): AlertRule["config"] { function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) { switch (form.rule_type) {
case "event_pattern": { case "event_pattern": {
@@ -198,6 +229,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
return { status: form.status, minutes: parseFloat(form.minutes) }; return { status: form.status, minutes: parseFloat(form.minutes) };
case "token_threshold": case "token_threshold":
return { total_tokens: parseInt(form.total_tokens, 10) }; return { total_tokens: parseInt(form.total_tokens, 10) };
case "cost_threshold":
return { usd: parseFloat(form.usd) };
case "budget": {
const config: AlertRule["config"] = {
period: form.period,
limit_usd: parseFloat(form.limit_usd),
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
tz_offset: new Date().getTimezoneOffset(),
};
if (form.budget_tool) config.tool = form.budget_tool;
return config;
}
} }
} }
@@ -225,6 +268,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes }); return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
case "token_threshold": case "token_threshold":
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() }); return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
case "cost_threshold":
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
case "budget":
return t("ruleDesc.budget", {
limit: fmtCost(c.limit_usd ?? 0),
period: t(`periods.${c.period ?? "month"}`),
thresholds: (c.thresholds ?? []).join("%, ") + "%",
});
} }
} }
@@ -244,6 +295,9 @@ export function AlertsNotifications() {
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null); const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
const [preview, setPreview] = useState<RulePreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const aiTools = useAiTools();
const ruleCtx = useContextMenu<AlertRule>(); const ruleCtx = useContextMenu<AlertRule>();
// A rule-menu item: close the menu, then act on the rule it was opened on. // A rule-menu item: close the menu, then act on the rule it was opened on.
const pickRule = (fn: (rule: AlertRule) => unknown) => () => { const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
@@ -322,10 +376,35 @@ export function AlertsNotifications() {
}); });
}, [loadAlerts]); }, [loadAlerts]);
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch })); // Any edit makes a shown preview stale.
const set = (patch: Partial<RuleFormState>) => {
setPreview(null);
setForm((prev) => ({ ...prev, ...patch }));
};
const onPreview = async () => {
if (previewing) return;
setPreviewing(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
setPreview(
await api.alerts.rules.preview({
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
})
);
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
} finally {
setPreviewing(false);
}
};
const closeForm = () => { const closeForm = () => {
setForm(EMPTY_FORM); setForm(EMPTY_FORM);
setPreview(null);
setEditingId(null); setEditingId(null);
setFormOpen(false); setFormOpen(false);
setFormError(null); setFormError(null);
@@ -333,6 +412,7 @@ export function AlertsNotifications() {
const openEditRule = (rule: AlertRule) => { const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule)); setForm(formFromRule(rule));
setPreview(null);
setEditingId(rule.id); setEditingId(rule.id);
setFormError(null); setFormError(null);
setFormOpen(true); setFormOpen(true);
@@ -417,7 +497,10 @@ export function AlertsNotifications() {
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) && (countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") || ((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
(Number.isFinite(minutesVal) && minutesVal > 0)) && (Number.isFinite(minutesVal) && minutesVal > 0)) &&
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)); (form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
(form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [ const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{ {
@@ -680,6 +763,100 @@ export function AlertsNotifications() {
</div> </div>
)} )}
{form.rule_type === "cost_threshold" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.usd")}
<FieldHelp description={t("rules.help.usd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.usd}
onChange={(e) => set({ usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
</div>
)}
{form.rule_type === "budget" && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.period")}
<FieldHelp description={t("rules.help.period")} />
</span>
<div className="relative mt-1">
<select
value={form.period}
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
className="input w-full appearance-none pr-8"
>
{(["day", "week", "month"] as const).map((p) => (
<option key={p} value={p}>
{t(`periods.${p}`)}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.limitUsd")}
<FieldHelp description={t("rules.help.limitUsd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.limit_usd}
onChange={(e) => set({ limit_usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.thresholds")}
<FieldHelp description={t("rules.help.thresholds")} />
</span>
<input
type="text"
value={form.thresholds}
onChange={(e) => set({ thresholds: e.target.value })}
placeholder="80, 100"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.budgetTool")}
<FieldHelp description={t("rules.help.budgetTool")} />
</span>
<div className="relative mt-1">
<select
value={form.budget_tool}
onChange={(e) => set({ budget_tool: e.target.value })}
className="input w-full appearance-none pr-8"
>
<option value="">{t("rules.form.allTools")}</option>
{aiTools.tools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
)}
{preview && <RulePreviewResult preview={preview} />}
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<label className="block text-xs text-fg-secondary"> <label className="block text-xs text-fg-secondary">
<span className="mb-1.5 flex items-center gap-1"> <span className="mb-1.5 flex items-center gap-1">
@@ -701,15 +878,30 @@ export function AlertsNotifications() {
</span> </span>
)} )}
</label> </label>
<div className="flex items-center gap-2">
<button
onClick={onPreview}
disabled={!canSubmit || previewing}
title={t("rules.previewHint")}
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye className="w-3.5 h-3.5" />
{previewing ? t("rules.previewing") : t("rules.preview")}
</button>
<button <button
onClick={onSaveRule} onClick={onSaveRule}
disabled={!canSubmit || saving} disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed" className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
> >
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />} {editingId ? (
<Check className="w-3.5 h-3.5" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")} {saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button> </button>
</div> </div>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>} {formError && <p className="text-xs text-status-danger">{formError}</p>}
</div> </div>
)} )}
@@ -927,3 +1119,51 @@ export function AlertsNotifications() {
</div> </div>
); );
} }
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
function RulePreviewResult({ preview }: { preview: RulePreview }) {
const { t } = useTranslation("alerts");
if (!preview.supported) {
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
}
if (preview.periods) {
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
{preview.periods.map((p) => (
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
<span className="text-fg-muted">{p.period_start}</span>
<span className="text-fg-secondary">
{fmtCost(p.spend)} · {Math.round(p.pct)}%
</span>
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
</span>
</div>
))}
</div>
);
}
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">
{t("rules.previewResult", {
fires: preview.fires,
sessions: preview.sessions,
days: preview.window_days,
})}
</p>
{preview.samples.map((s, i) => (
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
<Link
to={`/sessions/${encodeURIComponent(s.session_id)}`}
className="truncate hover:text-accent"
>
{s.session_id.slice(0, 8)}
</Link>
<span>{timeAgo(s.at)}</span>
</div>
))}
</div>
);
}
+2 -2
View File
@@ -85,14 +85,14 @@ export function ConfirmModal({
> >
<div <div
ref={panelRef} ref={panelRef}
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40" className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={message ? messageId : undefined} aria-describedby={message ? messageId : undefined}
> >
<div className="flex items-start gap-3 p-5"> <div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
{destructive && ( {destructive && (
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden /> <AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
+289
View File
@@ -0,0 +1,289 @@
/**
* @file EventReplay.tsx
* @description The session Replay tab: a scrubber over a session's recorded
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
* whose markers jump the cursor, the state derived up to the cursor
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
* events, so the live analysis page and a frozen snapshot share it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
import { api } from "../lib/api";
import {
chronological,
lifecycleMarkers,
replayState,
LIFECYCLE_TYPES,
REPLAY_SPEEDS,
REPLAY_STEP_MS,
} from "../lib/replay";
import { buildEventTitle } from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { formatDateTimeFull, formatMs } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
import { AgentStatusBadge } from "./StatusBadge";
import { EventDetail } from "./EventDetail";
/** The newest this many events are replayed; older ones are left out. */
export const REPLAY_MAX_EVENTS = 5000;
const PAGE = 500;
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
export async function loadReplayEvents(
sessionId: string
): Promise<{ events: DashboardEvent[]; total: number }> {
const all: DashboardEvent[] = [];
let total = 0;
do {
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
total = r.total;
if (r.events.length === 0) break;
all.push(...r.events);
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
}
const MARKER_COLOR: Record<string, string> = {
SessionStart: "bg-status-success",
UserPromptSubmit: "bg-accent",
Compaction: "bg-status-warning",
SubagentStop: "bg-violet-500",
APIError: "bg-status-danger",
Stop: "bg-fg-muted",
};
const btn =
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
export function EventReplay({
events: raw,
total,
agentInfoById,
sessionNameById,
}: {
events: DashboardEvent[];
/** The session's full event count, when more than `events` were recorded. */
total?: number;
agentInfoById: Map<string, AgentInfo>;
sessionNameById: Map<string, string>;
}) {
const { t } = useTranslation("sessions");
const events = useMemo(() => chronological(raw), [raw]);
const markers = useMemo(() => lifecycleMarkers(events), [events]);
const last = events.length - 1;
const [cursor, setCursor] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<number>(1);
const at = Math.min(cursor, Math.max(0, last));
const state = useMemo(() => replayState(events, at), [events, at]);
const event = events[at];
useEffect(() => {
if (!playing) return;
if (at >= last) {
setPlaying(false);
return;
}
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
return () => clearTimeout(timer);
}, [playing, at, last, speed]);
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
const toggle = () => {
if (!playing && at >= last) setCursor(0);
setPlaying((p) => !p);
};
const onKey = (e: KeyboardEvent) => {
// The slider steps itself on arrow keys; only the rest is ours there.
const onSlider = e.target instanceof HTMLInputElement;
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
e.preventDefault();
toggle();
} else if (onSlider) return;
else if (e.key === "ArrowLeft") go(at - 1);
else if (e.key === "ArrowRight") go(at + 1);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(last);
else return;
e.preventDefault();
};
const markerLabel = (type: string) => t(`replay.marker.${type}`);
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
return (
<div
className="space-y-4 outline-none"
tabIndex={0}
onKeyDown={onKey}
role="group"
aria-label={t("replay.label")}
data-testid="event-replay"
>
{total != null && total > events.length && (
<p className="text-xs text-fg-muted">
{t("replay.truncated", { shown: events.length, total })}
</p>
)}
<div className="card space-y-3 p-4">
<div className="flex flex-wrap items-center gap-1">
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className={btn}
onClick={() => go(at - 1)}
disabled={at <= 0}
title={t("replay.back")}
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className={`${btn} bg-accent/15 text-accent`}
onClick={toggle}
title={playing ? t("replay.pause") : t("replay.play")}
aria-pressed={playing}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className={btn}
onClick={() => go(at + 1)}
disabled={at >= last}
title={t("replay.forward")}
>
<StepForward className="h-4 w-4" />
</button>
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
<SkipForward className="h-4 w-4" />
</button>
<div
className="ml-2 flex items-center gap-0.5"
role="group"
aria-label={t("replay.speed")}
>
{REPLAY_SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
aria-pressed={speed === s}
className={`rounded px-2 py-1 text-xs font-mono ${
speed === s
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{s}×
</button>
))}
</div>
<span className="ml-auto font-mono text-xs text-fg-muted">
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
{formatDateTimeFull(event.created_at)}
</span>
</div>
<input
type="range"
min={0}
max={last}
value={at}
onChange={(e) => go(Number(e.target.value))}
aria-label={t("replay.scrubber")}
className="w-full accent-[rgb(var(--accent))]"
/>
<div
className="relative h-4 rounded bg-surface-2"
role="group"
aria-label={t("replay.minimap")}
>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
/>
{markers.map((m) => (
<button
key={m.index}
type="button"
onClick={() => go(m.index)}
title={`${markerLabel(m.type)} · #${m.index + 1}`}
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
style={{ left: `${m.pct}%` }}
/>
))}
</div>
{present.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
{present.map((k) => (
<span key={k} className="inline-flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
{markerLabel(k)}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
<Tile label={t("replay.state.status")}>
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
</Tile>
<Tile label={t("replay.state.tool")}>
<span className="font-mono">{state.currentTool ?? "-"}</span>
</Tile>
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
</div>
{state.toolCounts.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("replay.state.toolCounts")}
>
{state.toolCounts.map((x) => (
<span
key={x.tool}
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
>
{x.tool} ×{x.count}
</span>
))}
</div>
)}
<div className="card overflow-hidden">
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
{buildEventTitle(event)}
</div>
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
</div>
</div>
);
}
function Tile({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* @file EventTimeline.tsx
* @description A session's events as a flat, expandable list — one row per
* event (age, status badge, origin · title, tool), opening into EventDetail.
* Used by the session analysis page's Timeline tab and the read-only snapshot
* page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AgentStatusBadge } from "./StatusBadge";
import { EventDetail } from "./EventDetail";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { timeAgo } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
export function EventTimeline({
events,
agentInfoById,
sessionNameById,
fallbackCwd,
}: {
events: DashboardEvent[];
agentInfoById: Map<string, AgentInfo>;
sessionNameById: Map<string, string>;
/** The session's cwd, for events that carry none of their own. */
fallbackCwd?: string | null;
}) {
const { t } = useTranslation("sessions");
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
const projectByEventId = useMemo(() => {
const map = new Map<number, string | null>();
for (const e of events) map.set(e.id, projectFromEvent(e));
return map;
}, [events]);
return (
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
{events.map((event, i) => {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
<div
key={key}
style={
isOpen
? undefined
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
}
>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}
aria-expanded={isOpen}
aria-label={
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
}
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
>
<span
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
aria-hidden="true"
>
▶
</span>
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
{timeAgo(event.created_at)}
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
// Session is implicit on this page - project is
// still shown so the row identifies the working
// directory when you share / search. Events without
// their own cwd (e.g. TurnDuration) fall back to the
// session's cwd so every row carries the dir prefix.
const project =
projectByEventId.get(event.id) ?? projectFromCwd(fallbackCwd) ?? null;
const origin = buildOriginLabel(
project,
null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<span className="text-sm text-fg-secondary flex-1 truncate">
{origin && (
<span className="text-fg-muted mr-1" title={event.agent_id ?? undefined}>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</span>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
{event.tool_name}
</span>
)}
</button>
{isOpen && (
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
</div>
);
})}
</div>
</div>
);
}
+4
View File
@@ -81,6 +81,7 @@ import {
import { api, type ImportResult, type ImportBackupResult } from "../lib/api"; import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import type { WSMessage, ImportProgressMessage } from "../lib/types"; import type { WSMessage, ImportProgressMessage } from "../lib/types";
import { PathPickButton } from "./PathPickButton";
type Mode = "rescan" | "path" | "upload" | "backup"; type Mode = "rescan" | "path" | "upload" | "backup";
@@ -426,6 +427,7 @@ export function ImportHistory() {
{mode === "path" && ( {mode === "path" && (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="flex gap-1.5">
<input <input
type="text" type="text"
value={folderPath} value={folderPath}
@@ -434,6 +436,8 @@ export function ImportHistory() {
className="input w-full text-sm font-mono" className="input w-full text-sm font-mono"
spellCheck={false} spellCheck={false}
/> />
<PathPickButton start={folderPath} onPick={setFolderPath} />
</div>
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p> <p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
+78 -2
View File
@@ -12,6 +12,11 @@
* written back on every toggle. Failures to access storage are swallowed so a * written back on every toggle. Failures to access storage are swallowed so a
* private-browsing quota error never breaks the UI. * private-browsing quota error never breaks the UI.
* *
* ## Sidebar width
* The expanded sidebar's right edge drags (or takes ←/→) between
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
*
* ## Narrow viewports * ## Narrow viewports
* With no stored preference the sidebar starts collapsed below `lg` (see * With no stored preference the sidebar starts collapsed below `lg` (see
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu * {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
@@ -79,6 +84,26 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi"; import { Sagi } from "./Sagi/Sagi";
import { StatusBar } from "./StatusBar"; import { StatusBar } from "./StatusBar";
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
/** Wide enough for long workspace names on a large screen, no wider. */
export const SIDEBAR_MAX = 480;
/** A width inside the bounds, and at most 40% of the window. */
export function clampSidebar(px: number): number {
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
}
function loadWidth(): number {
try {
const n = Number(localStorage.getItem(WIDTH_KEY));
if (n) return clampSidebar(n);
} catch {}
return SIDEBAR_MIN;
}
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */ /** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
export const DRAWER_QUERY = "(max-width: 767.98px)"; export const DRAWER_QUERY = "(max-width: 767.98px)";
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = useCallback(() => setDrawerOpen(false), []); const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const { pathname, search } = useLocation(); const { pathname, search } = useLocation();
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem"; const [width, setWidth] = useState(loadWidth);
const [resizing, setResizing] = useState(false);
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
const saveWidth = useCallback((px: number) => {
const next = clampSidebar(px);
setWidth(next);
try {
localStorage.setItem(WIDTH_KEY, String(next));
} catch {}
}, []);
// A pick in the drawer navigates; the drawer has done its job. // A pick in the drawer navigates; the drawer has done its job.
useEffect(closeDrawer, [pathname, search, closeDrawer]); useEffect(closeDrawer, [pathname, search, closeDrawer]);
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
connectionOpen={connectionOpen} connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen} onConnectionOpenChange={setConnectionOpen}
onUpdateAvailableChange={setUpdateAvailable} onUpdateAvailableChange={setUpdateAvailable}
width={width}
resizing={resizing}
/> />
{!drawer && !collapsed && (
<div
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={SIDEBAR_MIN}
aria-valuemax={SIDEBAR_MAX}
aria-label={t("resizeSidebar")}
title={t("resizeSidebar")}
data-testid="sidebar-resize"
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
resizing ? "bg-accent/60" : ""
}`}
style={{ left: width }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
setResizing(true);
}}
onPointerMove={(e) => {
if (resizing) setWidth(clampSidebar(e.clientX));
}}
onPointerUp={(e) => {
if (!resizing) return;
setResizing(false);
saveWidth(e.clientX);
}}
onPointerCancel={() => setResizing(false)}
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
}}
/>
)}
<main <main
id="main-content" id="main-content"
tabIndex={-1} tabIndex={-1}
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none" className={`min-h-screen min-w-0 pb-6 duration-200 outline-none ${
resizing ? "" : "transition-[margin-left,width]"
}`}
style={{ style={{
marginLeft: sidebarWidth, marginLeft: sidebarWidth,
width: `calc(100% - ${sidebarWidth})`, width: `calc(100% - ${sidebarWidth})`,
+2 -2
View File
@@ -87,9 +87,9 @@ export function PageTabs({
aria-controls={hasPanel && isActive ? panelId : undefined} aria-controls={hasPanel && isActive ? panelId : undefined}
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(id)} onClick={() => onSelect(id)}
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${ className={`inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
isActive isActive
? "bg-accent/15 border-accent/30 text-accent font-semibold shadow-[inset_0_-2px_0_rgb(var(--accent))]" ? "bg-accent/15 border-accent/30 text-accent shadow-[inset_0_-2px_0_rgb(var(--accent))]"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3" : "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`} }`}
> >
+86
View File
@@ -0,0 +1,86 @@
/**
* @file PathPickButton.tsx
* @description The one "Browse" button beside every path field: it asks the
* server to open the operating system's own folder/file picker
* (`POST /api/lanes/pick-path`) and fills the field with the absolute path the
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
onPick,
showLabel = false,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
const [fallback, setFallback] = useState(false);
const title = kind === "dir" ? t("browse.title") : t("browse.fileTitle");
const pick = async () => {
setBusy(true);
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
};
return (
<>
<button
type="button"
onClick={pick}
disabled={busy}
title={title}
aria-label={title}
className="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
{showLabel && t("browse.button")}
</button>
{/* Its clicks stay here: a backdrop click must close only this modal,
not bubble on to a dialog this button sits in. */}
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
/>
</div>
</>
);
}
+56 -3
View File
@@ -1,7 +1,8 @@
/** /**
* @file RemoteSources.tsx * @file RemoteSources.tsx
* @description Settings UI for the Remote Data Sources feature: manage the SSH * @description Settings UI for the Remote Data Sources feature: manage the SSH
* machines this dashboard pulls Claude Code history from, and choose the global * machines this dashboard pulls Claude Code (and, opted in per source, Codex)
* history from, and choose the global
* "data scope" (which machines' data the whole app shows). * "data scope" (which machines' data the whole app shows).
* *
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the * Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
@@ -86,6 +87,7 @@ import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope"; import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope"; import type { ScopeMode } from "../lib/dataScope";
import { PathPickButton } from "./PathPickButton";
/** Data-scope choices in display order (the radio group's arrow-key order). */ /** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"]; const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
@@ -96,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
ssh_port: null, ssh_port: null,
identity_file: "", identity_file: "",
remote_home: "", remote_home: "",
sync_codex: false,
remote_codex_home: "",
enabled: true, enabled: true,
}; };
@@ -218,6 +222,8 @@ export function RemoteSources() {
ssh_port: s.ssh_port, ssh_port: s.ssh_port,
identity_file: s.identity_file || "", identity_file: s.identity_file || "",
remote_home: s.remote_home || "", remote_home: s.remote_home || "",
sync_codex: !!s.sync_codex,
remote_codex_home: s.remote_codex_home || "",
enabled: s.enabled, enabled: s.enabled,
}); });
setEditingId(s.id); setEditingId(s.id);
@@ -241,6 +247,8 @@ export function RemoteSources() {
ssh_port: form.ssh_port ? Number(form.ssh_port) : null, ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null, identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null, remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
sync_codex: !!form.sync_codex,
remote_codex_home: form.remote_codex_home?.trim() ? form.remote_codex_home.trim() : null,
enabled: form.enabled, enabled: form.enabled,
}; };
try { try {
@@ -558,12 +566,19 @@ export function RemoteSources() {
<label className="block text-xs text-fg-muted mb-1"> <label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldIdentity", "Identity file (optional)")} {t("remoteSources.fieldIdentity", "Identity file (optional)")}
</label> </label>
<div className="flex gap-1.5">
<input <input
className="input w-full font-mono" className="input w-full font-mono"
placeholder="~/.ssh/id_ed25519" placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""} value={form.identity_file ?? ""}
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
/> />
<PathPickButton
kind="file"
start={form.identity_file || "~/.ssh"}
onPick={(p) => setForm((f) => ({ ...f, identity_file: p }))}
/>
</div>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-xs text-fg-muted mb-1"> <label className="block text-xs text-fg-muted mb-1">
@@ -583,6 +598,31 @@ export function RemoteSources() {
</p> </p>
</div> </div>
</div> </div>
<Checkbox
checked={!!form.sync_codex}
onChange={(v) => setForm((f) => ({ ...f, sync_codex: v }))}
label={t("remoteSources.fieldSyncCodex", "Also pull Codex sessions")}
labelClassName="text-sm text-fg-secondary"
/>
{form.sync_codex && (
<div>
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldRemoteCodexHome", "Remote Codex home (optional)")}
</label>
<input
className="input w-full font-mono"
placeholder="~/.codex"
value={form.remote_codex_home ?? ""}
onChange={(e) => setForm((f) => ({ ...f, remote_codex_home: e.target.value }))}
/>
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
{t(
"remoteSources.fieldRemoteCodexHomeHint",
"Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost."
)}
</p>
</div>
)}
<Checkbox <Checkbox
checked={!!form.enabled} checked={!!form.enabled}
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))} onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
@@ -666,6 +706,9 @@ export function RemoteSources() {
{s.host} {s.host}
{s.ssh_port ? `:${s.ssh_port}` : ""} {s.ssh_port ? `:${s.ssh_port}` : ""}
{s.remote_home ? ` · ${s.remote_home}` : ""} {s.remote_home ? ` · ${s.remote_home}` : ""}
{s.sync_codex
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
: ""}
</div> </div>
<div className="text-[11px] text-fg-muted mt-1"> <div className="text-[11px] text-fg-muted mt-1">
{s.last_sync_at {s.last_sync_at
@@ -681,9 +724,19 @@ export function RemoteSources() {
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", { ` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
n: s.last_sync_counts.sessions_tagged, n: s.last_sync_counts.sessions_tagged,
})}`} })}`}
{s.last_sync_counts?.codex_sessions != null &&
` · ${t("remoteSources.syncCodex", "{{n}} Codex", {
n: s.last_sync_counts.codex_sessions,
})}`}
</div> </div>
{s.status === "error" && s.last_error && ( {/* A sync that pulled one CLI's history but not the other's is
<div className="text-[11px] text-status-danger mt-1 break-words"> `ok` with the other's failure in last_error. */}
{s.last_error && (s.status === "error" || s.status === "ok") && (
<div
className={`text-[11px] mt-1 break-words ${
s.status === "error" ? "text-status-danger" : "text-status-warning"
}`}
>
{s.last_error} {s.last_error}
</div> </div>
)} )}
+1 -1
View File
@@ -7,7 +7,7 @@
* codex-pets.net for more, and open the Sagi settings. The choice and the size * codex-pets.net for more, and open the Sagi settings. The choice and the size
* are viewer preferences (`prefs.ts`); the pets themselves live on the server * are viewer preferences (`prefs.ts`); the pets themselves live on the server
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared * (`server/lib/pets.js`); the list and its actions are `usePets`, shared
* with Settings → Pet. * with Settings → Appearance.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
+40 -7
View File
@@ -5,7 +5,10 @@
* animation state (idle, run right, run left, waving, jumping, failed, * animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review) — played one row at a time by Sagi's mood; an * waiting, running, review) — played one row at a time by Sagi's mood; an
* uploaded image is shown still and bobs with the same `data-mood` CSS as * uploaded image is shown still and bobs with the same `data-mood` CSS as
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to * Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
* or waving row over the mood's, like Codex's own pet. Each row steps at its
* own pace and holds its last frame longer, so a loop breathes instead of
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls * load (deleted from another browser) calls `onBroken`, so the mascot falls
* back to Sagi instead of an empty box. * back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,11 +33,28 @@ const ROW: Record<Mood, number> = {
worried: 5, // failed worried: 5, // failed
disconnected: 5, disconnected: 5,
}; };
/** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
const PACE: [number, number][] = [
[220, 1400], // idle: a breath, then a pause
[120, 120], // run right — keeps up with the drag
[120, 120], // run left
[180, 400], // waving
[160, 700], // jumping
[200, 1200], // failed
[220, 900], // waiting
[170, 400], // running (working)
[220, 900], // review
];
const SLEEP_PACE: [number, number] = [450, 2000];
export function PetSprite({ export function PetSprite({
id, id,
kind, kind,
mood, mood,
action,
size, size,
reducedMotion, reducedMotion,
onBroken, onBroken,
@@ -42,23 +62,36 @@ export function PetSprite({
id: string; id: string;
kind: "codex" | "image"; kind: "codex" | "image";
mood: Mood; mood: Mood;
action?: PetAction | null;
size: number; size: number;
reducedMotion: boolean; reducedMotion: boolean;
onBroken: () => void; onBroken: () => void;
}) { }) {
const url = api.pets.spriteUrl(id); const url = api.pets.spriteUrl(id);
const row = ROW[mood] ?? 0; const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
const sleeping = !action && mood === "sleeping";
const [frame, setFrame] = useState(0); const [frame, setFrame] = useState(0);
useEffect(() => { useEffect(() => {
setFrame(0); setFrame(0);
if (kind !== "codex" || reducedMotion) return; if (kind !== "codex" || reducedMotion) return;
const timer = window.setInterval( const count = FRAMES[row] ?? 1;
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)), const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
mood === "sleeping" ? 400 : 125 let f = 0;
let timer = 0;
const tick = () => {
timer = window.setTimeout(
() => {
f = (f + 1) % count;
setFrame(f);
tick();
},
f === count - 1 ? hold : step
); );
return () => window.clearInterval(timer); };
}, [kind, row, mood, reducedMotion]); tick();
return () => window.clearTimeout(timer);
}, [kind, row, sleeping, reducedMotion]);
// A background image reports no error of its own: probe it once. // A background image reports no error of its own: probe it once.
useEffect(() => { useEffect(() => {
+6 -1
View File
@@ -203,6 +203,7 @@ export function Sagi() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`). // The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion(); const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate(); const navigate = useNavigate();
const brain = useSagiBrain(); const brain = useSagiBrain();
@@ -315,7 +316,7 @@ export function Sagi() {
onClose={() => setMenu(false)} onClose={() => setMenu(false)}
onSettings={() => { onSettings={() => {
setMenu(false); setMenu(false);
navigate("/settings/pet"); navigate("/settings/appearance#pet");
}} }}
/> />
</SagiFlyout> </SagiFlyout>
@@ -331,6 +332,8 @@ export function Sagi() {
className="sagi-avatar-btn" className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"} data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }} style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onPointerDown={place.onPointerDown} onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove} onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp} onPointerUp={place.onPointerUp}
@@ -358,6 +361,8 @@ export function Sagi() {
id={pet.id} id={pet.id}
kind={pet.kind} kind={pet.kind}
mood={brain.mood} mood={brain.mood}
// Dragged → runs that way; hovered → waves (Codex's pet does both).
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
size={size} size={size}
reducedMotion={reducedMotion} reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi. // Deleted elsewhere, or unreadable: back to Sagi.
@@ -94,6 +94,8 @@ describe("PetSprite", () => {
expect(sprite.dataset.row).toBe("7"); expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px"); expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130)); act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
act(() => vi.advanceTimersByTime(50));
expect(sprite.style.backgroundPosition).toBe("-96px -728px"); expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender( rerender(
<PetSprite <PetSprite
@@ -108,6 +110,45 @@ describe("PetSprite", () => {
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5"); expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
}); });
it("runs the way it is dragged and waves on hover, over its mood", () => {
const props = {
id: "cp-goku",
kind: "codex" as const,
mood: "thinking" as const,
size: 208,
reducedMotion: false,
onBroken: () => {},
};
const { rerender } = render(<PetSprite {...props} action="run-left" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
rerender(<PetSprite {...props} action="run-right" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
rerender(<PetSprite {...props} action="wave" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
rerender(<PetSprite {...props} action={null} />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
});
it("holds a loop's last frame before starting over", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
const sprite = screen.getByTestId("pet-sprite");
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(1000));
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(400));
expect(sprite.style.backgroundPosition).toBe("0px 0px");
});
it("holds the first frame under reduced motion", () => { it("holds the first frame under reduced motion", () => {
render( render(
<PetSprite <PetSprite
@@ -159,7 +200,7 @@ describe("PetMenu", () => {
fireEvent.click(screen.getByText("Hide pet")); fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false); expect(sagiPrefs.getEnabled()).toBe(false);
// Says where to bring it back. // Says where to bring it back.
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true); expect(toasts.some((x) => /Settings → Appearance/.test(x.message))).toBe(true);
off(); off();
}); });
@@ -5,7 +5,16 @@
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react"; import {
render,
renderHook,
screen,
fireEvent,
act,
cleanup,
within,
} from "@testing-library/react";
import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi"; import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus"; import { eventBus } from "../../../lib/eventBus";
@@ -192,3 +201,22 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left"); expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
}); });
}); });
describe("useSagiPosition moving", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("names the drag's direction and settles once the pointer rests", () => {
const { result } = renderHook(() => useSagiPosition(68));
const target = { setPointerCapture() {}, releasePointerCapture() {} };
const ev = (x: number) =>
({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never;
act(() => result.current.onPointerDown(ev(500)));
act(() => result.current.onPointerMove(ev(480)));
expect(result.current.moving).toBe("left");
act(() => result.current.onPointerMove(ev(520)));
expect(result.current.moving).toBe("right");
act(() => vi.advanceTimersByTime(400));
expect(result.current.moving).toBeNull();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* @file usePets — the pet list and its actions, shared by the mascot's * @file usePets — the pet list and its actions, shared by the mascot's
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`): * right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`):
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing * `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
* a featured pet (downloaded on its first pick), uploading an image or a * a featured pet (downloaded on its first pick), uploading an image or a
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm * `.codex-pet.zip` (worn at once), and deleting your own after a confirm
+30 -3
View File
@@ -7,6 +7,8 @@
* viewport fraction so it survives resizes). A small movement threshold tells * viewport fraction so it survives resizes). A small movement threshold tells
* a drag apart from a tap so dragging never opens the panel. The arrow keys * a drag apart from a tap so dragging never opens the panel. The arrow keys
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it. * move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
* `moving` names the way it is heading — while dragged (cleared once the
* pointer rests) and during the glide to its edge — so a pet can run that way.
* It never rests over the fixed bottom status bar; by default it docks * It never rests over the fixed bottom status bar; by default it docks
* bottom-right just above it. * bottom-right just above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -83,6 +85,8 @@ const DRAG_THRESHOLD = 5;
const STATUS_BAR = 24; const STATUS_BAR = 24;
// One arrow-key step, in px. // One arrow-key step, in px.
const KEY_STEP = 40; const KEY_STEP = 40;
// A held but unmoving drag stops running after this; also the snap glide's length.
const MOVE_SETTLE_MS = 300;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024); const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768); const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
@@ -109,6 +113,8 @@ export interface SagiPlacement {
/** True when the avatar sits in the lower half - flyouts open upward. */ /** True when the avatar sits in the lower half - flyouts open upward. */
openUp: boolean; openUp: boolean;
dragging: boolean; dragging: boolean;
/** The way the avatar is moving horizontally right now, if it is. */
moving: "left" | "right" | null;
onPointerDown: (e: ReactPointerEvent) => void; onPointerDown: (e: ReactPointerEvent) => void;
onPointerMove: (e: ReactPointerEvent) => void; onPointerMove: (e: ReactPointerEvent) => void;
onPointerUp: (e: ReactPointerEvent) => void; onPointerUp: (e: ReactPointerEvent) => void;
@@ -123,6 +129,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos()); const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null); const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
const [, force] = useState(0); // re-derive resting coords on resize const [, force] = useState(0); // re-derive resting coords on resize
const [moving, setMoving] = useState<"left" | "right" | null>(null);
const moveTimer = useRef(0);
const lastX = useRef(0);
const heading = useCallback((dir: "left" | "right" | null) => {
window.clearTimeout(moveTimer.current);
setMoving(dir);
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
}, []);
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
const draggedRef = useRef(false); const draggedRef = useRef(false);
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null); const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
@@ -152,6 +167,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
/* capture unsupported - window-free fallback still works via props */ /* capture unsupported - window-free fallback still works via props */
} }
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top }; startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
lastX.current = e.clientX;
movedRef.current = false; movedRef.current = false;
}, },
[screen.left, screen.top] [screen.left, screen.top]
@@ -172,8 +188,14 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
); );
liveRef.current = { left, top }; liveRef.current = { left, top };
setDrag({ left, top }); setDrag({ left, top });
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
const stepX = e.clientX - lastX.current;
if (Math.abs(stepX) >= 2) {
lastX.current = e.clientX;
heading(stepX > 0 ? "right" : "left");
}
}, },
[size] [size, heading]
); );
const onPointerUp = useCallback( const onPointerUp = useCallback(
@@ -193,12 +215,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
sagiPrefs.setPos(next); sagiPrefs.setPos(next);
setPos(next); setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over setDrag(null); // leave drag mode; resting coords (with transition) take over
// Run along the glide to the edge, when there is a real distance to cover.
const glide = restingScreen(next, size).left - live.left;
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
} }
liveRef.current = null; liveRef.current = null;
startRef.current = null; startRef.current = null;
movedRef.current = false; movedRef.current = false;
}, },
[size] [size, heading]
); );
const onKeyDown = useCallback( const onKeyDown = useCallback(
@@ -214,10 +239,11 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
} }
if (!next) return; if (!next) return;
e.preventDefault(); e.preventDefault();
if (next.side !== pos.side) heading(next.side);
sagiPrefs.setPos(next); sagiPrefs.setPos(next);
setPos(next); setPos(next);
}, },
[pos, size] [pos, size, heading]
); );
const consumeDrag = useCallback(() => { const consumeDrag = useCallback(() => {
@@ -233,6 +259,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
side: pos.side, side: pos.side,
openUp: screen.top + size / 2 > vh() / 2, openUp: screen.top + size / 2 > vh() / 2,
dragging: drag !== null, dragging: drag !== null,
moving,
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
+9 -4
View File
@@ -76,6 +76,8 @@ import type { Agent, Session, SessionStats } from "../lib/types";
interface SessionOverviewProps { interface SessionOverviewProps {
session: Session; session: Session;
agents: Agent[]; agents: Agent[];
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
stats?: SessionStats;
} }
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */ /** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
@@ -160,18 +162,19 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
); );
} }
export function SessionOverview({ session, agents }: SessionOverviewProps) { export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const [stats, setStats] = useState<SessionStats | null>(null); const [fetched, setStats] = useState<SessionStats | null>(null);
const stats = frozen ?? fetched;
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
// Tick clock every 30s so a still-active session's "duration" tile stays current. // Tick clock every 30s so a still-active session's "duration" tile stays current.
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
if (session.status !== "active") return; if (frozen || session.status !== "active") return;
const id = window.setInterval(() => setNow(Date.now()), 30_000); const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [session.status]); }, [frozen, session.status]);
// A refresh asked for while one is in flight is not dropped: it runs once // A refresh asked for while one is in flight is not dropped: it runs once
// when the current one settles (that is the throttle's trailing call). // when the current one settles (that is the throttle's trailing call).
@@ -198,6 +201,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Initial load + reload when the session id changes. // Initial load + reload when the session id changes.
useEffect(() => { useEffect(() => {
if (frozen) return;
fetchStats(); fetchStats();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]); }, [session.id]);
@@ -206,6 +210,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// stream still refreshes) and again after a reconnect. // stream still refreshes) and again after a reconnect.
useLiveRefresh(() => void fetchStats(), { useLiveRefresh(() => void fetchStats(), {
throttleMs: REFRESH_THROTTLE_MS, throttleMs: REFRESH_THROTTLE_MS,
enabled: !frozen,
match: (msg) => { match: (msg) => {
if (isRemoteDataRefreshMessage(msg)) return true; if (isRemoteDataRefreshMessage(msg)) return true;
if ( if (
+18 -9
View File
@@ -170,6 +170,10 @@ interface SidebarProps {
onUpdateAvailableChange?: (available: boolean) => void; onUpdateAvailableChange?: (available: boolean) => void;
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */ /** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
drawer?: { open: boolean; onClose: () => void }; drawer?: { open: boolean; onClose: () => void };
/** Expanded width in px, chosen by dragging its edge (see Layout). */
width?: number;
/** True while that edge is dragged: no width transition lagging the pointer. */
resizing?: boolean;
} }
export function Sidebar({ export function Sidebar({
@@ -180,6 +184,8 @@ export function Sidebar({
onConnectionOpenChange, onConnectionOpenChange,
onUpdateAvailableChange, onUpdateAvailableChange,
drawer, drawer,
width = 240,
resizing = false,
}: SidebarProps) { }: SidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const asideRef = useRef<HTMLElement>(null); const asideRef = useRef<HTMLElement>(null);
@@ -396,13 +402,15 @@ export function Sidebar({
onUpdateAvailableChange?.(updateAvailable); onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]); }, [updateAvailable, onUpdateAvailableChange]);
// Same density and selection as a workspace row, so the whole sidebar
// reads as one list: no border, the active item tinted with an accent icon.
const navClass = ({ isActive }: { isActive: boolean }) => const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${ `flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5" collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
} ${ } ${
isActive isActive
? "bg-accent/10 text-accent border border-accent/20" ? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`; }`;
return ( return (
@@ -421,8 +429,9 @@ export function Sidebar({
? "visible translate-x-0 shadow-2xl transition-transform" ? "visible translate-x-0 shadow-2xl transition-transform"
: "invisible -translate-x-full transition-[transform,visibility]" : "invisible -translate-x-full transition-[transform,visibility]"
}` }`
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}` : `z-30 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
}`} }`}
style={drawer || collapsed ? undefined : { width }}
> >
<div className="px-3 py-4 border-b border-border flex-shrink-0"> <div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
@@ -451,7 +460,7 @@ export function Sidebar({
Chevron buttons appear at the edges when content is clipped, so the Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */} user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex"> <div className="flex-1 min-h-0 relative flex">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 space-y-1"> <nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
{NAV_KEYS.map(({ to, icon: Icon, key }) => { {NAV_KEYS.map(({ to, icon: Icon, key }) => {
const label = t(key); const label = t(key);
return ( return (
@@ -475,7 +484,7 @@ export function Sidebar({
onClick={onToggle} onClick={onToggle}
title={t("lanes:tree.title")} title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")} aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary" className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
> >
<FolderTree className="w-4 h-4" /> <FolderTree className="w-4 h-4" />
</button> </button>
@@ -528,8 +537,8 @@ export function Sidebar({
data-testid="sidebar-toggle" data-testid="sidebar-toggle"
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")} title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")} aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${ className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0" collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`} }`}
> >
{collapsed ? ( {collapsed ? (
+84
View File
@@ -0,0 +1,84 @@
/**
* @file SnapshotButton.tsx
* @description The session analysis page's **Snapshot** action: confirms with a
* "redact file paths" choice, freezes the session through `POST
* /api/snapshots`, copies the `/snapshot/<token>` link and offers it inline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Camera } from "lucide-react";
import { api } from "../lib/api";
import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { CopyButton } from "./event-views/primitives";
export function snapshotUrl(token: string): string {
return `${window.location.origin}/snapshot/${token}`;
}
export function SnapshotButton({ sessionId }: { sessionId: string }) {
const { t } = useTranslation("sessions");
const [open, setOpen] = useState(false);
const [redact, setRedact] = useState(true);
const [busy, setBusy] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const create = async () => {
setBusy(true);
setError(null);
try {
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
setToken(snapshot.token);
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setOpen(false);
}
};
return (
<div className="flex flex-wrap items-center justify-end gap-1">
{token && (
<span className="inline-flex items-center gap-1 text-xs text-fg-secondary">
<Link to={`/snapshot/${token}`} className="text-accent hover:underline">
{t("snapshot.open")}
</Link>
<CopyButton text={snapshotUrl(token)} />
</span>
)}
<button
type="button"
onClick={() => setOpen(true)}
title={t("snapshot.hint")}
className="btn-ghost text-xs"
>
<Camera className="h-4 w-4" />
{t("snapshot.create")}
</button>
{error && (
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
{error}
</p>
)}
<ConfirmModal
open={open}
title={t("snapshot.title")}
message={t("snapshot.body")}
confirmLabel={t("snapshot.confirm")}
cancelLabel={t("snapshot.cancel")}
destructive={false}
busy={busy}
onConfirm={() => void create()}
onCancel={() => setOpen(false)}
>
<Checkbox checked={redact} onChange={setRedact} label={t("snapshot.redactPaths")} />
</ConfirmModal>
</div>
);
}
+366 -30
View File
@@ -2,34 +2,325 @@
* @file StatusBar — the one-line bar along the bottom of the app shell, on * @file StatusBar — the one-line bar along the bottom of the app shell, on
* every page: the live-connection state (click: the connection details modal, * every page: the live-connection state (click: the connection details modal,
* which the sidebar owns and which also carries the update check), the version * which the sidebar owns and which also carries the update check), the version
* — with a green dot while a newer release is out —, how many workspaces are running and how * — with a green dot while a newer release is out —, how many SESSIONS are
* many need the user (from the shared lane list, no request of its own), and * running and how many need the user (`GET /api/sessions?status=active` and
* today's spend — the `daily_costs` bucket for the local date from * `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* `GET /api/pricing/cost`, the Dashboard's own cost call — and, on the * messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the * Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for * sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll, * the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and * `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. No provider quota: reading Claude's quota rotates * whenever the URL names another workspace or session. Each figure carries its
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane. * token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
* its fullest window, live account only — and, on hover or focus, a panel
* above grouping every account per tool (saved Claude profiles too): name,
* sign-in expiry, and a row per window with its bar, percent and reset time.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState } from "react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react"; import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api"; import { api, type RunHandle } from "../lib/api";
import { useLanes } from "../hooks/useLanes"; import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree"; import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000; const COST_POLL_MS = 30_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
function localDate(d = new Date()): string { function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
} }
const usd = (n: number) => `$${n.toFixed(2)}`;
/** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded),
* costliest first: the hover text of a cost figure. */
function perModel(r: CostResult): { tokens: number; lines: string } {
const byModel = new Map<string, { tokens: number; cost: number }>();
for (const b of r.breakdown) {
const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 };
m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens;
m.cost += b.cost;
byModel.set(b.model, m);
}
const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost);
return {
tokens: rows.reduce((n, [, m]) => n + m.tokens, 0),
lines: rows
.map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`)
.join("\n"),
};
}
function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) {
const { t } = useTranslation("nav");
const { tokens, lines } = perModel(r);
return (
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
</span>
);
}
/** Re-run `load` every `ms` and whenever the tab becomes visible again. */
function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) {
useEffect(() => {
const tick = () => document.visibilityState === "visible" && load();
tick();
const id = setInterval(tick, ms);
document.addEventListener("visibilitychange", tick);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0));
const quotaTone = (p: number) =>
p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : "";
const barTone = (p: number) =>
p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted";
function untilText(iso: string | null): string | null {
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
if (!(ms > 0)) return null;
const m = Math.floor(ms / 60_000);
const d = Math.floor(m / 1440);
const h = Math.floor((m % 1440) / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
}
/** "account · sign-in state · as of", whichever parts the entry has. */
function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) => string) {
const until = untilText(q.expiresAt ?? null);
return [
q.label && q.account,
q.expiresAt &&
q.error !== "ELIVEEXPIRED" &&
(until
? t("statusBar.quotaSignInExpires", { time: until })
: t("statusBar.quotaSignInExpired")),
q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }),
]
.filter(Boolean)
.join(" · ");
}
const QUOTA_ERROR_KEY: Record<string, string> = {
ELIVEEXPIRED: "statusBar.quotaExpired",
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
};
/** One account: its name, sign-in line, then a row per window with its reset. */
function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
const { t } = useTranslation("nav");
const meta = quotaMeta(q, t);
return (
<div data-testid="status-bar-quota-account" className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{q.label || q.account}</span>
{many && q.active !== false && (
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
{t("statusBar.quotaActive")}
</span>
)}
</div>
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
{q.error && !q.windows.length ? (
<div className="text-status-warning">
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
</div>
) : (
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
{q.windows.map((w) => {
const p = Math.round(w.usedPercent ?? 0);
const resets = untilText(w.resetsAt);
return (
<Fragment key={w.label}>
<span className="text-fg-muted">
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
</span>
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full ${barTone(p)}`}
style={{ width: `${Math.min(p, 100)}%` }}
/>
</span>
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
<span className="flex items-center justify-end gap-1 text-fg-muted">
{resets && (
<>
<RotateCcw className="h-3 w-3" aria-hidden />
<span aria-hidden>{resets}</span>
<span className="sr-only">
{t("statusBar.quotaResets", { time: resets })}
</span>
</>
)}
</span>
</Fragment>
);
})}
</div>
)}
</div>
);
}
function QuotaPanel({
tools,
onEnter,
onLeave,
onRefresh,
refreshing,
}: {
tools: ToolQuota[];
/** The footer's Refresh: re-reads the quota, as a click on the chips does. */
onRefresh: () => void;
/** A forced read is in flight: the Refresh icon spins. */
refreshing: boolean;
/** The pointer moved onto the panel / off it: it stays open while read. */
onEnter: () => void;
onLeave: () => void;
}) {
const { t } = useTranslation("nav");
const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][])
.map((id) => [id, tools.filter((q) => q.id === id)] as const)
.filter(([, list]) => list.length);
return createPortal(
<div
role="dialog"
aria-label={t("statusBar.quotaTitle")}
data-testid="status-bar-quota-panel"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
onFocus={onEnter}
onBlur={onLeave}
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
>
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
{groups.map(([id, list]) => (
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
<div className="mb-1 flex items-center gap-1.5">
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
</div>
<div className="space-y-2 pl-[1.375rem]">
{list.map((q, i) => (
<QuotaAccount
key={`${q.label ?? q.account ?? ""}-${i}`}
q={q}
many={list.length > 1}
/>
))}
</div>
</div>
))}
<button
type="button"
data-testid="status-bar-quota-refresh"
// Not `disabled`: a focused button that turns disabled loses focus, and
// that blur would close the panel under the pointer.
onClick={() => !refreshing && onRefresh()}
aria-busy={refreshing}
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
refreshing ? "cursor-default" : "hover:text-fg-primary"
}`}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
</button>
</div>,
document.body
);
}
/** The right-end quota chips; nothing at all until a tool reports one. */
function QuotaChips() {
const { t } = useTranslation("nav");
const [tools, setTools] = useState<ToolQuota[]>([]);
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>();
const show = () => {
window.clearTimeout(closeTimer.current);
setOpen(true);
};
const hide = () => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
};
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback((force = false) => {
if (force) setRefreshing(true);
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined)
.finally(() => force && setRefreshing(false));
}, []);
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
if (!tools.length) return null;
return (
<>
<button
type="button"
data-testid="status-bar-quota"
data-tip-off
onClick={() => load(true)}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
>
{tools
.filter((q) => q.active !== false)
.map((q) => {
const p = Math.round(fullest(q));
return (
<span key={q.id} className="flex items-center gap-1">
<AiToolIcon
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
className="h-4 w-4 !p-0"
/>
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
{q.error ? (
<span className="text-status-warning">!</span>
) : (
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
)}
</span>
);
})}
</button>
{open && (
<QuotaPanel
tools={tools}
onEnter={show}
onLeave={hide}
onRefresh={() => load(true)}
refreshing={refreshing}
/>
)}
</>
);
}
export function StatusBar({ export function StatusBar({
wsConnected, wsConnected,
left, left,
@@ -44,13 +335,15 @@ export function StatusBar({
updateAvailable?: boolean; updateAvailable?: boolean;
}) { }) {
const { t } = useTranslation("nav"); const { t } = useTranslation("nav");
const { lanes } = useLanes(); const [active, setActive] = useState(0);
const running = lanes.filter((l) => l.status === "running").length; const [needYou, setNeedYou] = useState(0);
const needYou = lanes.filter((l) => l.needs_action).length; const running = Math.max(active - needYou, 0);
const [today, setToday] = useState<number | null>(null); const [today, setToday] = useState<CostResult | null>(null);
const [laneCost, setLaneCost] = useState<number | null>(null); const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<number | null>(null); const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
const [runs, setRuns] = useState<RunHandle[]>([]); const [runs, setRuns] = useState<RunHandle[]>([]);
// The budget furthest past one of its thresholds, if any.
const [budget, setBudget] = useState<BudgetStatus | null>(null);
const location = useLocation(); const location = useLocation();
const shown = selectedFrom(location.pathname, location.search); const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane; const lane = shown.lane;
@@ -63,6 +356,22 @@ export function StatusBar({
return () => window.removeEventListener(LIVE_RUNS_EVENT, take); return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []); }, []);
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
useEffect(() => { useEffect(() => {
let live = true; let live = true;
// Another workspace or session: never show the previous one's figure. // Another workspace or session: never show the previous one's figure.
@@ -71,19 +380,26 @@ export function StatusBar({
const tick = () => { const tick = () => {
if (document.visibilityState !== "visible") return; if (document.visibilityState !== "visible") return;
api.pricing api.pricing
.totalCost() .totalCost({ date: localDate() })
.then((r) => live && setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0)) .then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined); .catch(() => undefined);
if (lane !== null) { if (lane !== null) {
api.pricing api.pricing
.totalCost({ lane }) .totalCost({ lane })
.then((r) => live && setLaneCost(r.total_cost)) .then((r) => live && setLaneCost(r))
.catch(() => undefined); .catch(() => undefined);
} }
if (sessionId) { if (sessionId) {
api.pricing api.pricing
.sessionCost(sessionId) .sessionCost(sessionId)
.then((r) => live && setSessionCost(r.total_cost)) .then((r) => live && setSessionCost(r))
.catch(() => undefined); .catch(() => undefined);
} }
}; };
@@ -96,7 +412,6 @@ export function StatusBar({
document.removeEventListener("visibilitychange", tick); document.removeEventListener("visibilitychange", tick);
}; };
}, [lane, sessionId]); }, [lane, sessionId]);
const usd = (n: number) => `$${n.toFixed(2)}`;
return ( return (
<footer <footer
@@ -144,20 +459,41 @@ export function StatusBar({
</span> </span>
</span> </span>
{today !== null && ( {today !== null && (
<span data-testid="status-bar-cost" className="tabular-nums"> <CostFigure
{t("statusBar.today", { cost: usd(today) })} testId="status-bar-cost"
</span> label={t("statusBar.today", { cost: usd(today.total_cost) })}
r={today}
/>
)} )}
{lane !== null && laneCost !== null && ( {lane !== null && laneCost !== null && (
<span data-testid="status-bar-workspace-cost" className="tabular-nums"> <CostFigure
{t("statusBar.workspace", { cost: usd(laneCost) })} testId="status-bar-workspace-cost"
</span> label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
r={laneCost}
/>
)} )}
{sessionId && sessionCost !== null && ( {sessionId && sessionCost !== null && (
<span data-testid="status-bar-session-cost" className="tabular-nums"> <CostFigure
{t("statusBar.session", { cost: usd(sessionCost) })} testId="status-bar-session-cost"
</span> label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
r={sessionCost}
/>
)} )}
{budget && (
<Link
to="/?tab=analytics&sub=cost"
data-testid="status-bar-budget"
title={t("statusBar.budgetTitle", {
name: budget.name,
spend: usd(budget.spend),
limit: usd(budget.config.limit_usd ?? 0),
})}
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
>
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
</Link>
)}
<QuotaChips />
</footer> </footer>
); );
} }
+36 -11
View File
@@ -29,8 +29,9 @@
* ## Dismissal persistence * ## Dismissal persistence
* Dismissals are keyed in `localStorage` by the release tag when there is one, * Dismissals are keyed in `localStorage` by the release tag when there is one,
* else the upstream `remote_sha`, so a newer release re-opens the prompt while * else the upstream `remote_sha`, so a newer release re-opens the prompt while
* a dismissed one stays dismissed. Settings can reset dismissal via the * a dismissed one stays dismissed. The connection modal's "Check for updates"
* `dashboard:reset-update-dismissal` window event. * fires `dashboard:reset-update-dismissal`, which clears both the skip and the
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -84,6 +85,7 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
import { api } from "../lib/api"; import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog"; import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -184,7 +186,11 @@ export function UpdateNotifier() {
}, [syncFromPayload]); }, [syncFromPayload]);
useEffect(() => { useEffect(() => {
const handler = () => setDismissedSha(null); // An explicit "Check for updates" undoes both kinds of close.
const handler = () => {
setDismissedSha(null);
setClosedKey(null);
};
window.addEventListener("dashboard:reset-update-dismissal", handler); window.addEventListener("dashboard:reset-update-dismissal", handler);
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler); return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
}, []); }, []);
@@ -259,13 +265,30 @@ export function UpdateNotifier() {
* stretch, so polling naively would get a 200 from the process we are * stretch, so polling naively would get a 200 from the process we are
* waiting to replace and reload onto the same old bundle. * waiting to replace and reload onto the same old bundle.
*/ */
const pollUntilBack = useCallback((requireDownFirst = false) => { const pollUntilBack = useCallback(
(requireDownFirst = false, fromVersion: string | null = null) => {
let sawDown = !requireDownFirst; let sawDown = !requireDownFirst;
// Its own verdicts only: an older plugin-update.json is still on disk
// until this chain writes one (5 s slack for a clock a little behind).
const since = Date.now() - 5000;
const attempt = () => { const attempt = () => {
api.updates api.updates
.status() .status()
.then(() => { .then((s) => {
if (sawDown) window.location.reload(); const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
// never goes down, so waiting for that spun "Restarting…" forever.
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
syncFromPayload(s);
setRestarting(false);
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
return;
}
// A new version answering is back too, even if a poll never
// landed in the short gap between the old server and the new one.
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
if (sawDown || moved) window.location.reload();
else setTimeout(attempt, 1500); else setTimeout(attempt, 1500);
}) })
.catch(() => { .catch(() => {
@@ -274,7 +297,9 @@ export function UpdateNotifier() {
}); });
}; };
setTimeout(attempt, 1500); setTimeout(attempt, 1500);
}, []); },
[syncFromPayload, t]
);
const applyNow = async () => { const applyNow = async () => {
if (applying || restarting) return; if (applying || restarting) return;
@@ -287,7 +312,7 @@ export function UpdateNotifier() {
setRestarting(true); setRestarting(true);
// mode "plugin": the old server lingers while the chain installs, so // mode "plugin": the old server lingers while the chain installs, so
// only treat a reachable server as "back" after it has gone away once. // only treat a reachable server as "back" after it has gone away once.
pollUntilBack(result.mode === "plugin"); pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
return; return;
} }
setError( setError(
@@ -313,7 +338,7 @@ export function UpdateNotifier() {
return ( return (
<div <div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in" className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="update-notifier-title" aria-labelledby="update-notifier-title"
@@ -325,7 +350,7 @@ export function UpdateNotifier() {
longest label is a whole sentence ("restarting the dashboard…"). */} longest label is a whole sentence ("restarting the dashboard…"). */}
<div <div
ref={panelRef} ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden" className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border"> <div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
@@ -365,7 +390,7 @@ export function UpdateNotifier() {
</div> </div>
{/* Body */} {/* Body */}
<div className="px-5 py-4 space-y-3"> <div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
<p className="text-sm text-fg-secondary leading-relaxed"> <p className="text-sm text-fg-secondary leading-relaxed">
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")} {pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
</p> </p>
+75 -32
View File
@@ -3,9 +3,8 @@
* Desktop's project list: every workspace (lane) on one line in the stored drag * Desktop's project list: every workspace (lane) on one line in the stored drag
* order (`laneOrderStorage.ts`), each expanding to ITS sessions * order (`laneOrderStorage.ts`), each expanding to ITS sessions
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a * (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
* workspace opens its tabs (`/workspace?lane=<id>&run=<runId>`, the pane it * workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
* last focused) when it has live ones, else its home (`?lane=<id>`, also what * live tab is opened from its session row. Live status comes from the shared
* `+` on a row opens for a new session). Live status comes from the shared
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a * lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
* lane's status or session moves and handed over by the Workspace page's own * lane's status or session moves and handed over by the Workspace page's own
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and * poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
@@ -14,23 +13,24 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react"; import { Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api"; import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types"; import type { Lane } from "../lib/types";
import { loadLayout } from "../lib/paneLayout";
import { useLanes } from "../hooks/useLanes"; import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
import { import {
insertionIndex,
moveBefore,
moveLane, moveLane,
readLaneOrder, readLaneOrder,
sortByStoredOrder, sortByStoredOrder,
writeLaneOrder, writeLaneOrder,
} from "../lib/laneOrderStorage"; } from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs"; import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { WorkspaceRow, workspaceName } from "./lanes/WorkspaceRow"; import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList"; import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { AddLaneModal } from "./lanes/AddLaneModal"; import { AddLaneModal } from "./lanes/AddLaneModal";
@@ -100,16 +100,6 @@ export function WorkspaceTree() {
}; };
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]); const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
// A row click: a workspace with live tabs opens on them — on the pane it
// last focused — and one with none on its home (the tab bar's Home button
// reaches the home of the other kind).
const open = (id: number) => {
const runs = liveRuns.filter((r) => r.laneId === id && r.status === "running");
const focus = loadLayout(id).focus;
const run = runs.find((r) => (r.slot ?? 0) === focus) ?? runs[0];
if (run) navigate(`/workspace?lane=${id}&run=${encodeURIComponent(run.id)}`);
else select(id);
};
const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim(); const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim();
const setExpanded = (id: number, open: boolean) => const setExpanded = (id: number, open: boolean) =>
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } }); writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
@@ -142,9 +132,9 @@ export function WorkspaceTree() {
}, [q]); }, [q]);
const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0; const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0;
const reorder = (from: number, to: number) => { const reorder = (move: (ids: number[]) => number[]) => {
const ids = sorted.map((l) => l.id); const ids = sorted.map((l) => l.id);
const moved = moveLane(ids, from, to); const moved = move(ids);
if (moved === ids) return; if (moved === ids) return;
// Under a tool filter the shown ids go back into the slots they held in // Under a tool filter the shown ids go back into the slots they held in
// the full order, so the hidden workspaces keep their places. // the full order, so the hidden workspaces keep their places.
@@ -157,6 +147,44 @@ export function WorkspaceTree() {
setOrderTick((n) => n + 1); setOrderTick((n) => n + 1);
}; };
// A row only starts a drag; the whole section takes the drop, so a gap — a
// workspace's session list, the space above or below a row — lands too.
// `dropAt` is the insertion point in `sorted`, drawn as a line.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
const dropIndexAt = (y: number) => {
const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-lane-index]") ?? [])];
const mids = rows.map((r) => {
const b = r.getBoundingClientRect();
return b.top + b.height / 2;
});
const row = rows[insertionIndex(mids, y)];
return row ? Number(row.dataset.laneIndex) : sorted.length;
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientY);
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
setDropAt(null);
if (!isLaneDrag(e)) return;
e.preventDefault();
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
const before = dropIndexAt(e.clientY);
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
},
};
const rename = async (lane: Lane, title: string | null) => { const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null); setRenamingId(null);
if (title === null || title === (lane.title || "")) return; if (title === null || title === (lane.title || "")) return;
@@ -168,17 +196,10 @@ export function WorkspaceTree() {
} }
}; };
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction, runId?: string) => { const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
if (action === "rename") setRenamingId(lane.id); if (action === "rename") setRenamingId(lane.id);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd); else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action }); else if (action === "reset" || action === "remove") setDestructive({ lane, action });
// Start opens the session it started, as the home's menu does.
else if (action === "started")
navigate(
runId
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`
: `/workspace?lane=${lane.id}`
);
}; };
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => { const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
@@ -201,7 +222,12 @@ export function WorkspaceTree() {
// ≥24px targets (WCAG 2.5.8); each carries an aria-label. // ≥24px targets (WCAG 2.5.8); each carries an aria-label.
const headBtn = "btn-icon"; const headBtn = "btn-icon";
return ( return (
<section data-testid="workspace-tree" className="mt-3 border-t border-border pt-3"> <section
data-testid="workspace-tree"
// The bottom padding is a drop strip below the last workspace.
className="mt-3 border-t border-border pb-6 pt-3"
{...dragHandlers}
>
<div className="mb-1 flex items-center gap-1 px-2"> <div className="mb-1 flex items-center gap-1 px-2">
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted"> <span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
{t("tree.title")} {t("tree.title")}
@@ -264,9 +290,10 @@ export function WorkspaceTree() {
/> />
)} )}
<div className="space-y-px px-1"> <div ref={listRef} className="space-y-px px-1">
{sorted.map((lane, i) => ( {sorted.map((lane, i) => (
<div key={lane.id}> <div key={lane.id} className="relative">
{dropAt === i && <DropLine />}
{visible(lane) && ( {visible(lane) && (
<WorkspaceRow <WorkspaceRow
lane={lane} lane={lane}
@@ -275,12 +302,12 @@ export function WorkspaceTree() {
expanded={expanded(lane.id)} expanded={expanded(lane.id)}
renaming={renamingId === lane.id} renaming={renamingId === lane.id}
onSelect={() => { onSelect={() => {
open(lane.id); select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true); if (!expanded(lane.id)) setExpanded(lane.id, true);
}} }}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))} onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)} onNewSession={() => select(lane.id)}
onReorder={reorder} onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })} onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)} onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)} onRenameEnd={(title) => void rename(lane, title)}
@@ -300,6 +327,11 @@ export function WorkspaceTree() {
)} )}
</div> </div>
))} ))}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
</div>
)}
</div> </div>
{error && ( {error && (
@@ -314,7 +346,7 @@ export function WorkspaceTree() {
x={menu.x} x={menu.x}
y={menu.y} y={menu.y}
onClose={() => setMenu(null)} onClose={() => setMenu(null)}
onAction={(a, runId) => onMenuAction(menu.lane, a, runId)} onAction={(a) => onMenuAction(menu.lane, a)}
/> />
)} )}
{destructive && ( {destructive && (
@@ -342,3 +374,14 @@ export function WorkspaceTree() {
</section> </section>
); );
} }
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
<div
aria-hidden
data-testid="workspace-drop-line"
className="pointer-events-none absolute inset-x-1 -top-px z-10 h-0.5 rounded-full bg-accent-solid"
/>
);
}
@@ -0,0 +1,102 @@
/**
* @file Tests for the alert rule form's spend rules: a budget rule is built
* from period / limit / thresholds / AI tool (plus the browser's timezone
* offset), Preview shows what it would have fired without saving it, and
* Create saves the same config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
vi.mock("../../lib/api", () => ({
api: {
alerts: {
list: vi.fn().mockResolvedValue({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
rules: {
list: vi.fn().mockResolvedValue({ rules: [] }),
create: vi.fn().mockResolvedValue({ rule: {} }),
preview: vi.fn().mockResolvedValue({
supported: true,
window_days: 7,
fires: 1,
sessions: 0,
samples: [],
periods: [
{
period_start: "2026-09-01",
period_end: "2026-09-30",
spend: 120,
pct: 120,
crossed: [80, 100],
},
{
period_start: "2026-10-01",
period_end: "2026-10-31",
spend: 10,
pct: 10,
crossed: [],
},
],
}),
},
},
aiTools: {
list: vi.fn().mockResolvedValue({
tools: [{ id: "codex", label: "Codex" }],
default: "claude",
}),
},
},
}));
import { api } from "../../lib/api";
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications — spend rules", () => {
it("previews and creates a budget rule", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
await user.click(await screen.findByText("Add rule"));
await user.type(screen.getByPlaceholderText("e.g. Error burst"), "Monthly");
const selects = screen.getAllByRole("combobox");
await user.selectOptions(selects[0]!, "budget");
await user.selectOptions(screen.getByDisplayValue("month"), "week");
const limit = screen.getByDisplayValue("100");
await user.clear(limit);
await user.type(limit, "50");
await screen.findByRole("option", { name: "Codex" });
await user.selectOptions(screen.getByDisplayValue("All AI tools"), "codex");
await user.click(screen.getByRole("button", { name: /Preview/ }));
const config = {
period: "week",
limit_usd: 50,
thresholds: [80, 100],
tz_offset: new Date().getTimezoneOffset(),
tool: "codex",
};
expect(api.alerts.rules.preview).toHaveBeenCalledWith({
rule_type: "budget",
config,
cooldown_seconds: 300,
});
expect(await screen.findByText("2026-09-01")).toBeInTheDocument();
expect(screen.getByText("80% 100%")).toBeInTheDocument();
expect(api.alerts.rules.create).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /Create rule/ }));
expect(api.alerts.rules.create).toHaveBeenCalledWith({
name: "Monthly",
rule_type: "budget",
config,
cooldown_seconds: 300,
});
});
});
@@ -0,0 +1,93 @@
/**
* @file EventReplay.test.tsx
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
* marker jumping the cursor, playback advancing on a timer, and the derived
* state following the cursor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { EventReplay } from "../EventReplay";
import type { DashboardEvent } from "../../lib/types";
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
return {
id,
session_id: "s1",
agent_id: "a1",
event_type,
tool_name,
summary: `${event_type} ${tool_name ?? ""}`.trim(),
data: null,
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
} as DashboardEvent;
}
// Given newest first, as the events API answers: the replay orders them itself.
const events = [
ev(4, "Stop"),
ev(3, "PostToolUse", "Bash"),
ev(2, "PreToolUse", "Bash"),
ev(1, "SessionStart"),
];
function renderReplay() {
return render(
<MemoryRouter>
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
</MemoryRouter>
);
}
const position = () => screen.getByText(/^\d+ \/ 4/);
afterEach(() => vi.useRealTimers());
describe("EventReplay", () => {
it("starts at the first event and steps with the buttons", () => {
renderReplay();
expect(position().textContent).toMatch(/^1 \/ 4/);
fireEvent.click(screen.getByTitle("Step forward (→)"));
fireEvent.click(screen.getByTitle("Step forward (→)"));
expect(position().textContent).toMatch(/^3 \/ 4/);
expect(screen.getByText("Bash ×1")).toBeTruthy();
fireEvent.click(screen.getByTitle("Jump to end (End)"));
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("moves on arrow keys and jumps from a lifecycle marker", () => {
renderReplay();
const root = screen.getByTestId("event-replay");
fireEvent.keyDown(root, { key: "ArrowRight" });
expect(position().textContent).toMatch(/^2 \/ 4/);
// The in-flight tool at the PreToolUse.
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
fireEvent.keyDown(root, { key: "End" });
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("plays to the end at the chosen speed", () => {
vi.useFakeTimers();
renderReplay();
fireEvent.click(screen.getByRole("button", { name: "5×" }));
fireEvent.click(screen.getByTitle("Play (Space)"));
act(() => {
vi.advanceTimersByTime(100);
});
expect(position().textContent).toMatch(/^2 \/ 4/);
// One step per tick: each step schedules the next.
for (let i = 0; i < 3; i++) {
act(() => {
vi.advanceTimersByTime(100);
});
}
expect(position().textContent).toMatch(/^4 \/ 4/);
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
});
});
@@ -3,7 +3,8 @@
* @description Responsive shell: with no stored preference the sidebar starts * @description Responsive shell: with no stored preference the sidebar starts
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened * collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
* from a menu button — a modal dialog that Escape and the backdrop close, * from a menu button — a modal dialog that Escape and the backdrop close,
* handing focus back to the button. * handing focus back to the button. The expanded sidebar's edge resizes it
* within its bounds and remembers the width.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
vi.mock("../StatusBar", () => ({ StatusBar: () => null })); vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null })); vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
import { Layout, DRAWER_QUERY } from "../Layout"; import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout";
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar"; import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
function viewport(width: number) { function viewport(width: number) {
@@ -34,6 +35,7 @@ function viewport(width: number) {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
localStorage.removeItem(SIDEBAR_STORAGE_KEY); localStorage.removeItem(SIDEBAR_STORAGE_KEY);
localStorage.removeItem("agenthub.sidebar.width");
}); });
describe("loadCollapsed", () => { describe("loadCollapsed", () => {
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem"); expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
}); });
}); });
describe("Layout sidebar width", () => {
it("the edge drags within bounds, is remembered, and a double-click resets it", () => {
viewport(1440);
vi.stubGlobal("innerWidth", 2560);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
const edge = screen.getByTestId("sidebar-resize");
const main = screen.getByRole("main");
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerMove(edge, { clientX: 360 });
fireEvent.pointerUp(edge, { clientX: 360 });
expect(main.style.marginLeft).toBe("360px");
expect(screen.getByTestId("sidebar").style.width).toBe("360px");
expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360");
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerUp(edge, { clientX: 2000 });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`);
fireEvent.keyDown(edge, { key: "ArrowLeft" });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`);
fireEvent.doubleClick(edge);
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
});
it("never wider than 40% of the window", () => {
viewport(1100);
vi.stubGlobal("innerWidth", 1100);
localStorage.setItem("agenthub.sidebar.width", "480");
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
expect(screen.getByRole("main").style.marginLeft).toBe("440px");
});
});
@@ -0,0 +1,69 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* @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 { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { PathPickButton } from "../PathPickButton";
vi.mock("../../lib/api", () => ({
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
}));
vi.mock("../../lib/toast", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
const fail = (code: string) => Object.assign(new Error(code), { code });
beforeEach(() => {
vi.mocked(api.lanes.pickPath).mockReset();
vi.mocked(api.lanes.browse)
.mockReset()
.mockResolvedValue({ path: "/tmp", parent: "/", entries: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.error).mockReset();
});
describe("PathPickButton", () => {
it("fills the field with the chosen path and leaves it alone on Cancel", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ path: "/home/u/proj" });
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ cancelled: true });
render(<PathPickButton start=" /home/u " onPick={onPick} />);
const button = screen.getByTitle("Browse for a folder");
await userEvent.click(button);
await waitFor(() => expect(onPick).toHaveBeenCalledWith("/home/u/proj"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("dir", "/home/u");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(onPick).toHaveBeenCalledTimes(1);
});
it("opens the in-app folder browser on any failure, not only a missing picker", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EBUSY"));
render(<PathPickButton onPick={vi.fn()} />);
await userEvent.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByRole("dialog", { name: "Browse for a folder" })).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
expect(screen.queryByRole("dialog")).toBeNull();
});
});
@@ -2,17 +2,20 @@
* @file StatusBar.test.tsx * @file StatusBar.test.tsx
* @description Tests for the shell's bottom status bar: the live/disconnected * @description Tests for the shell's bottom status bar: the live/disconnected
* state with the version (click opens the connection details), the running and * state with the version (click opens the connection details), the running and
* needs-you counts from the shared lane list, today's spend from the * needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
* pricing cost call's `daily_costs`, and on the Workspace page the open * with its tokens and a per-model hover list, and on the Workspace page the
* workspace's and the shown session's totals. * open workspace's and the shown session's totals; the right-end quota chips
* (fullest window per tool) and their hover panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { act, render, screen } from "@testing-library/react"; import { act, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
const M = 1_000_000;
function today() { function today() {
const d = new Date(); const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
@@ -21,31 +24,108 @@ function today() {
vi.mock("../../lib/api", () => ({ vi.mock("../../lib/api", () => ({
api: { api: {
lanes: { sessions: {
list: vi.fn().mockResolvedValue({ // 3 active sessions, 1 of them waiting on the user.
lanes: [ list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
{ id: 1, status: "running", needs_action: null }, sessions: [],
{ id: 2, status: "running", needs_action: "permission" }, total: p.awaiting ? 1 : 3,
{ id: 3, status: "idle", needs_action: null }, })),
],
counts: {},
}),
}, },
pricing: { pricing: {
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number }) => ({ totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number; date?: string }) => ({
total_cost: opts?.lane ? 123.456 : 10, total_cost: opts?.lane ? 123.456 : opts?.date === today() ? 1.234 : 10,
breakdown: [], breakdown: opts?.lane
daily_costs: [ ? []
{ date: "2000-01-01", cost: 9 }, : [
{ date: today(), cost: 1.234 }, // Two buckets of one model fold into one line.
{
model: "claude-opus-4-8",
input_tokens: 1 * M,
output_tokens: 0,
cache_read_tokens: 10 * M,
cache_write_tokens: 0,
cost: 1,
matched_rule: null,
},
{
model: "claude-opus-4-8",
speed: "fast",
input_tokens: 0,
output_tokens: 1 * M,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.2,
matched_rule: null,
},
{
model: "claude-haiku-4-5",
input_tokens: 300_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.034,
matched_rule: null,
},
], ],
daily_costs: [],
})), })),
quota: vi.fn().mockResolvedValue({
tools: [
{
id: "claude",
active: true,
label: "Work",
account: "me@example.com",
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
windows: [
{
label: "5h",
usedPercent: 95,
resetsAt: new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(),
},
{ label: "wk", usedPercent: 32, resetsAt: null },
],
},
{
id: "claude",
active: false,
label: "Personal",
account: "home@example.com",
expiresAt: "2020-01-01T00:00:00.000Z",
windows: [],
error: "EPROFILEEXPIRED",
},
{
id: "codex",
account: "dev@example.com",
expiresAt: "2020-01-01T00:00:00.000Z",
windows: [],
error: "EUPSTREAM",
},
],
checkedAt: "",
}),
sessionCost: vi.fn().mockImplementation(async (id: string) => ({ sessionCost: vi.fn().mockImplementation(async (id: string) => ({
total_cost: id === "s-live" ? 4.5 : 7, total_cost: id === "s-live" ? 4.5 : 7,
breakdown: [], breakdown: [],
daily_costs: [], daily_costs: [],
})), })),
}, },
budgets: {
status: vi.fn().mockResolvedValue({
budgets: [
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
{
id: "b2",
name: "Monthly",
config: { limit_usd: 100 },
spend: 85,
pct: 85,
crossed: [80],
},
],
}),
},
}, },
})); }));
@@ -63,7 +143,13 @@ describe("StatusBar", () => {
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />); at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
const conn = screen.getByRole("button", { name: /Live/ }); const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`); expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
expect(await screen.findByText("Today $1.23")).toBeInTheDocument(); const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
expect(cost).toHaveAttribute(
"title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you"); expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
await user.click(conn); await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled(); expect(onConnectionClick).toHaveBeenCalled();
@@ -71,6 +157,14 @@ describe("StatusBar", () => {
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you"); expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
}); });
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chip = await screen.findByTestId("status-bar-budget");
expect(chip.textContent).toBe("Budget 85%");
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
});
it("says Disconnected when the socket is down", () => { it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />); at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
@@ -79,7 +173,7 @@ describe("StatusBar", () => {
it("off the Workspace page shows no workspace or session cost", async () => { it("off the Workspace page shows no workspace or session cost", async () => {
at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />); at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
await screen.findByText("Today $1.23"); await screen.findByText(/^Today \$1\.23/);
expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull(); expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull();
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull(); expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
}); });
@@ -89,8 +183,8 @@ describe("StatusBar", () => {
"/workspace?lane=3&session=s-old", "/workspace?lane=3&session=s-old",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} /> <StatusBar wsConnected left="0" onConnectionClick={() => {}} />
); );
expect(await screen.findByText("Workspace $123.46")).toBeInTheDocument(); expect(await screen.findByText("Workspace $123.46 (0 Tokens)")).toBeInTheDocument();
expect(await screen.findByText("Session $7.00")).toBeInTheDocument(); expect(await screen.findByText(/^Session \$7\.00/)).toBeInTheDocument();
expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 }); expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 });
}); });
@@ -99,7 +193,7 @@ describe("StatusBar", () => {
"/workspace?lane=3&run=agenthub-lane-3-1", "/workspace?lane=3&run=agenthub-lane-3-1",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} /> <StatusBar wsConnected left="0" onConnectionClick={() => {}} />
); );
await screen.findByText("Workspace $123.46"); await screen.findByText(/^Workspace \$123\.46/);
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull(); expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
act(() => { act(() => {
window.dispatchEvent( window.dispatchEvent(
@@ -108,6 +202,54 @@ describe("StatusBar", () => {
}) })
); );
}); });
expect(await screen.findByText("Session $4.50")).toBeInTheDocument(); expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
});
it("shows each tool's fullest quota window and the details on hover", async () => {
const user = userEvent.setup();
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chips = await screen.findByTestId("status-bar-quota");
expect(chips).toHaveTextContent("Claude95% used");
expect(chips.textContent?.match(/Claude/g)).toHaveLength(1);
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
await user.hover(chips);
const panel = screen.getByTestId("status-bar-quota-panel");
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workin use");
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
expect(work).toHaveTextContent("5h95%41m");
expect(work).toHaveTextContent("Week32%");
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
expect(personal).not.toHaveTextContent("in use");
expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired");
expect(panel).toHaveTextContent("Could not read the quota");
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
await user.unhover(chips);
await user.hover(panel);
await new Promise((r) => setTimeout(r, 300));
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
// Only the footer button refreshes: it spins and says so until the read lands.
const same = await api.pricing.quota(false);
let answer!: (v: unknown) => void;
vi.mocked(api.pricing.quota)
.mockClear()
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never);
const refresh = screen.getByTestId("status-bar-quota-refresh");
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
expect(refresh).toHaveTextContent("Refreshing…");
expect(refresh).toHaveAttribute("aria-busy", "true");
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
await act(async () => answer(same));
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
await user.unhover(panel);
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
vi.mocked(api.pricing.quota).mockClear();
await user.click(chips);
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
}); });
}); });
@@ -222,6 +222,40 @@ describe("UpdateNotifier — Update now", () => {
}); });
}); });
describe("UpdateNotifier — plugin chain verdict", () => {
it("stops Restarting when the chain fails and the old server stays up", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
// The old server answers again, now carrying this chain's failed verdict.
statusMock.mockResolvedValue({
...PLUGIN,
last_plugin_update: {
ok: false,
step: "plugin_update",
error: "boom",
finished_at: new Date().toISOString(),
},
});
expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument();
});
});
describe("UpdateNotifier — closing vs skipping", () => { describe("UpdateNotifier — closing vs skipping", () => {
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => { it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
const { unmount } = render(<UpdateNotifier />); const { unmount } = render(<UpdateNotifier />);
@@ -238,6 +272,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb"); expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
}); });
it("Check for updates re-opens a modal closed with Later", async () => {
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
});
it("ignores Escape while an update is applying", async () => { it("ignores Escape while an update is applying", async () => {
applyMock.mockReturnValue(new Promise(() => {})); applyMock.mockReturnValue(new Promise(() => {}));
render(<UpdateNotifier />); render(<UpdateNotifier />);
@@ -81,12 +81,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
})), })),
cwds: vi.fn(async () => ({ items: [] })), cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })), kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
forgetHistory: vi.fn(async () => ({ removed: "" })),
}, },
aiTools: { aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })), list: vi.fn(async () => ({ tools: [], default: "claude" })),
iconUrl: () => "", iconUrl: () => "",
}, },
aiRouters: { list: vi.fn(async () => ({ routers: [] })) }, aiProxies: { list: vi.fn(async () => ({ items: [] })) },
}, },
}; };
}); });
@@ -307,7 +309,7 @@ describe("WorkspaceTree", () => {
expect(api.lanes.action).not.toHaveBeenCalled(); expect(api.lanes.action).not.toHaveBeenCalled();
}); });
it("shows a live tab whose session has no row yet as a New session, never a shell", async () => { it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
vi.mocked(api.run.list).mockImplementation( vi.mocked(api.run.list).mockImplementation(
async () => async () =>
({ ({
@@ -341,27 +343,119 @@ describe("WorkspaceTree", () => {
await user.click(await screen.findByTestId("workspace-toggle-1")); await user.click(await screen.findByTestId("workspace-toggle-1"));
const fresh = await screen.findByTestId("workspace-session-new-agenthub-lane-1"); const fresh = await screen.findByTestId("workspace-session-new-agenthub-lane-1");
expect(fresh).toHaveTextContent("New session"); expect(fresh).toHaveTextContent("New session");
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-3")).toBeNull(); expect(screen.getByTestId("workspace-session-new-agenthub-lane-1-3")).toHaveTextContent(
"Terminal"
);
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-2")).toBeNull(); expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-2")).toBeNull();
await user.click(fresh); await user.click(fresh);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1"); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
}); });
it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => {
vi.mocked(api.run.history).mockImplementation(
async () =>
({
items: [
{
id: "agenthub-lane-1-2",
tool: "shell",
status: "running",
started_at: "2026-10-06T10:00:00Z",
ended_at: null,
isLive: true,
},
{
id: "agenthub-lane-1-3@a",
tool: "shell",
status: "killed",
started_at: "2026-10-06T09:00:00Z",
ended_at: "2026-10-06T09:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@b",
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@c",
tool: "shell",
status: "exited",
started_at: "2026-10-06T07:00:00Z",
ended_at: "2026-10-06T07:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@d",
tool: "shell",
status: "exited",
started_at: "2026-10-06T06:00:00Z",
ended_at: "2026-10-06T06:05:00Z",
isLive: false,
},
],
}) as never
);
vi.mocked(api.lanes.action).mockImplementation(
async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a");
expect(closed).toHaveTextContent("Terminal");
expect(api.run.history).toHaveBeenCalledWith(
expect.objectContaining({ laneId: 1, tool: "shell" })
);
// The live one is not history, and only the newest three are listed.
expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull();
expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3);
await user.click(closed);
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" });
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("removes a closed terminal from its hover X and its right-click menu", async () => {
const shell = (id: string) => ({
id,
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
});
vi.mocked(api.run.history).mockImplementation(
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
await user.click(screen.getByText("Remove from list"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
});
it("selects by URL, and a click selects and expands", async () => { it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderTree("/workspace?lane=2"); renderTree("/workspace?lane=2");
expect(await screen.findByTestId("workspace-open-2")).toHaveAttribute("aria-current", "true"); expect(await screen.findByTestId("workspace-open-2")).toHaveAttribute("aria-current", "true");
// Lane 1 has a live tab: its row opens the tab, not the home. // Lane 1 has a live tab: its row still opens the home.
await waitFor(() => expect(api.run.list).toHaveBeenCalled()); await waitFor(() => expect(api.run.list).toHaveBeenCalled());
await user.click(screen.getByTestId("workspace-open-1")); await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2"); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
expect(await screen.findByTestId("workspace-sessions-1")).toBeInTheDocument(); expect(await screen.findByTestId("workspace-sessions-1")).toBeInTheDocument();
// Lane 2 has none: its home.
await user.click(screen.getByTestId("workspace-open-2")); await user.click(screen.getByTestId("workspace-open-2"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=2"); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=2");
}); });
it("opens a workspace on the pane it last focused, and follows the page's run list", async () => { it("opens a workspace's home even while it has live tabs", async () => {
vi.mocked(api.run.list).mockImplementation(async () => ({ items: [] }) as never); vi.mocked(api.run.list).mockImplementation(async () => ({ items: [] }) as never);
localStorage.setItem( localStorage.setItem(
"agenthub.workspace.paneLayout", "agenthub.workspace.paneLayout",
@@ -393,7 +487,7 @@ describe("WorkspaceTree", () => {
); );
}); });
await user.click(screen.getByTestId("workspace-open-1")); await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2"); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
}); });
it("reorders with Alt+ArrowUp and keeps the order in localStorage", async () => { it("reorders with Alt+ArrowUp and keeps the order in localStorage", async () => {
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
aria-modal="true" aria-modal="true"
aria-labelledby="cc-confirm-delete-title" aria-labelledby="cc-confirm-delete-title"
aria-describedby="cc-confirm-delete-body" aria-describedby="cc-confirm-delete-body"
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1" className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2"> <div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
{t("edit.confirmDelete")} {t("edit.confirmDelete")}
</span> </span>
</div> </div>
<div className="p-4 space-y-3"> <div className="min-h-0 overflow-y-auto p-4 space-y-3">
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed"> <p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
{t("edit.confirmDeleteBody")} {t("edit.confirmDeleteBody")}
</p> </p>
@@ -21,6 +21,7 @@ import { api, type CwdSuggestion } from "../../lib/api";
import { confirmDiscard } from "../../lib/confirm"; import { confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { PathPickButton } from "../PathPickButton";
interface ProjectMemoryModalProps { interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */ /** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
@@ -133,6 +134,7 @@ export function ProjectMemoryModal({
> >
{t("projectMemory.dirLabel")} {t("projectMemory.dirLabel")}
</label> </label>
<div className="flex gap-1.5">
<input <input
id={`${listId}-dir`} id={`${listId}-dir`}
list={listId} list={listId}
@@ -142,6 +144,8 @@ export function ProjectMemoryModal({
spellCheck={false} spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50" className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/> />
<PathPickButton start={dir} onPick={setDir} />
</div>
<datalist id={listId}> <datalist id={listId}>
{suggestions.map((s) => ( {suggestions.map((s) => (
<option key={s.path} value={s.path}> <option key={s.path} value={s.path}>
+79 -52
View File
@@ -8,21 +8,21 @@
* manually-typed branch name. The user also picks which CLI drives the lane * manually-typed branch name. The user also picks which CLI drives the lane
* (Claude Code or Codex) — this choice is immutable after creation. Either * (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete * mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by browsing (`FolderBrowseModal`) — a native folder picker * suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
* cannot hand a web page an absolute filesystem path, so browsing is * opened by the server, the in-app `FolderBrowseModal` where it cannot be).
* server-backed instead. The worktree lane returned is `status: "provisioning"`: * The worktree lane returned is `status: "provisioning"`:
* the route answers before the actual `git worktree add` runs, so this * the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the * 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. * auto-setup calls against a cwd that must actually exist on disk first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, FolderOpen } from "lucide-react"; import { ChevronDown, X } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal"; import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup"; import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal"; import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types"; import type { Lane, LaneWorktree } from "../../lib/types";
@@ -124,7 +124,6 @@ export function AddLaneModal({
const noTools = aiTools.loaded && aiTools.defaultId === null; const noTools = aiTools.loaded && aiTools.defaultId === null;
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{ const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed"; profile: "scaffolded" | "skipped" | "failed";
mcp: "ok" | "failed"; mcp: "ok" | "failed";
@@ -306,23 +305,18 @@ export function AddLaneModal({
} }
}; };
// ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s // Escape, the backdrop, the X and Cancel all land here. A folder browser
// identity. Every keystroke in the fields below re-renders this component; an // open on top answers its own Escape and backdrop (PathPickButton).
// inline `() => {...}` handed to `onCancel` would get a new identity each
// time, re-running that effect and yanking focus off the field being typed
// into after the very first character. useCallback keeps the identity stable
// across renders so only mount/unmount (and a real onClose change) refocuses.
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
// While the folder browser is open on top of it, that dismissal should
// close only the browser, not both modals at once.
if (browseOpen) {
setBrowseOpen(false);
return;
}
reset(); reset();
onClose(); onClose();
}, [onClose, browseOpen]); }, [onClose]);
// Cancel takes focus on open, the safer default.
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
useDialog(open, panelRef, handleCancel, cancelRef);
// A summary on screen means the lane already exists and a step failed, so // 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 // the confirm button has no second creation to run: it closes instead. That
@@ -341,19 +335,38 @@ export function AddLaneModal({
); );
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b)); const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
if (!open) return null;
// Its own shell, not ConfirmModal: that one lays its body beside the title in
// the X button's row, which left this form narrower than its own footer.
return ( return (
<ConfirmModal <div
open={open} className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
title={t("addLane")} onClick={handleCancel}
confirmLabel={done ? t("addLaneSetupClose") : t("add")} role="presentation"
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={disabled}
onConfirm={done ? handleCancel : submit}
onCancel={handleCancel}
> >
<div className="space-y-3"> <div
ref={panelRef}
className="relative flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
{t("addLane")}
</h3>
<button
type="button"
onClick={handleCancel}
className="btn-icon -mr-1"
aria-label={t("destructive.cancel")}
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5"> <div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg <Seg
active={mode === "repo"} active={mode === "repo"}
@@ -442,15 +455,7 @@ export function AddLaneModal({
suggestions={cwdSuggestions} suggestions={cwdSuggestions}
/> />
</div> </div>
<button <PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
type="button"
onClick={() => setBrowseOpen(true)}
title={t("browse.title")}
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
>
<FolderOpen className="h-3.5 w-3.5" />
{t("browse.button")}
</button>
</div> </div>
<p className="mt-1 text-[10px] text-fg-muted"> <p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")} {mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
@@ -578,7 +583,10 @@ export function AddLaneModal({
)} )}
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch"> <label
className="mb-1 block text-xs text-fg-secondary"
htmlFor="add-lane-branch"
>
{t("addLaneBranchLabel")} {t("addLaneBranchLabel")}
</label> </label>
<input <input
@@ -596,7 +604,10 @@ export function AddLaneModal({
</> </>
) : ( ) : (
<div> <div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing"> <label
className="mb-1 block text-xs text-fg-secondary"
htmlFor="add-lane-existing"
>
{t("addLaneBranchExistingLabel")} {t("addLaneBranchExistingLabel")}
</label> </label>
{freeBranches.length === 0 ? ( {freeBranches.length === 0 ? (
@@ -640,7 +651,10 @@ export function AddLaneModal({
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" }, { label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
] as const ] as const
).map((row) => ( ).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary"> <p
key={row.label}
className="flex items-center gap-1.5 text-[11px] text-fg-primary"
>
<span <span
className={ className={
"skipped" in row && row.skipped "skipped" in row && row.skipped
@@ -665,12 +679,25 @@ export function AddLaneModal({
)} )}
</div> </div>
<FolderBrowseModal <div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
open={browseOpen} <button
initialPath={sourceRepo.trim() || undefined} ref={cancelRef}
onSelect={setSourceRepo} type="button"
onClose={() => setBrowseOpen(false)} onClick={handleCancel}
/> className="btn-ghost border border-border text-xs"
</ConfirmModal> >
{t("destructive.cancel")}
</button>
<button
type="button"
onClick={done ? handleCancel : submit}
disabled={busy || disabled}
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
</button>
</div>
</div>
</div>
); );
} }
+330
View File
@@ -0,0 +1,330 @@
/**
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
* file's original text (HEAD, or a branch's merge base), editable while the
* diff re-colours as you type. `unified` is one editor with the removed lines
* inline above their replacement (`unifiedMergeView`); `split` is the
* original read-only on the left beside the editable file (`MergeView`).
* `collapse` folds unchanged stretches away — the panel's Changes view —
* and every changed block carries a Revert that puts the original back in the
* editor (nothing is written until Save). Green marks a line added whole, red
* a line removed whole, blue a line edited in place (it keeps some of its
* text), its edit in a stronger blue — the same scheme as the read-only diff in
* LaneDiffView. Syntax colours come from the dashboard's own per-line
* tokenizer (`lib/highlight.ts`), like every other code view here.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { EditorState, RangeSetBuilder } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
drawSelection,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
import { highlight, tokenClass } from "../../lib/highlight";
/**
* True when a letter or digit of a line (`text`, starting at document offset
* `from`) lies outside every changed range: an edit inside the line, not a
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
* a `}`) does not make two lines the same line.
*/
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
for (let i = 0; i < text.length; i += 1) {
if (!/\w/.test(text[i]!)) continue;
const at = from + i;
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
}
return false;
}
const modLine = Decoration.line({ class: "cm-ah-mod" });
const marks = new Map<string, Decoration>();
const mark = (cls: string) => {
let d = marks.get(cls);
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
return d;
};
/** Syntax marks and the edited-in-place class for the visible lines — on
* either side of a split view (`a` is the original). */
function decorate(view: EditorView, lang: string): DecorationSet {
const out = new RangeSetBuilder<Decoration>();
const info = getChunks(view.state);
const a = info?.side === "a";
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
const chunks = info?.chunks ?? [];
const doc = view.state.doc;
let last = 0;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = doc.lineAt(pos);
pos = line.to + 1;
if (line.number <= last) continue;
last = line.number;
const c = chunks.find((k) => {
const [from, to, end] = span(k);
return to! > from! && line.from >= from! && line.from <= end!;
});
const at0 = c ? span(c)[0]! : 0;
if (
c &&
keepsText(
line.text,
line.from,
c.changes.map((ch) =>
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
)
)
) {
out.add(line.from, line.from, modLine);
}
let at = line.from;
for (const tok of highlight(line.text, lang)) {
const cls = tokenClass(tok.type);
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
at += tok.text.length;
}
}
}
return out.finish();
}
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
* classed after the fact, by `keepsText`'s rule: text outside its
* `.cm-deletedText` spans is text the line kept. */
function classDeleted(root: HTMLElement) {
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
let kept = "";
(line.firstElementChild ?? line).childNodes.forEach((n) => {
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
kept += n.textContent ?? "";
}
});
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
});
}
const diffColours = (lang: string) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = decorate(view, lang);
this.classLater(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
this.classLater(u.view);
}
// After CodeMirror has drawn, and before the browser paints.
classLater(view: EditorView) {
view.requestMeasure({
key: this,
read: () => null,
write: () => classDeleted(view.contentDOM),
});
}
},
{ decorations: (p) => p.decorations }
);
const GREEN = "rgb(var(--status-success) / 0.18)";
const RED = "rgb(var(--status-danger) / 0.18)";
const BLUE = "rgb(var(--diff-mod) / 0.18)";
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
// base theme, whose selectors carry an extra light/dark scope class.
const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({
"&": {
height: "100%",
fontSize: "11px",
backgroundColor: "rgb(var(--surface-0))",
color: "rgb(var(--fg-primary))",
},
"&.cm-focused": { outline: "none" },
".cm-scroller": {
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
lineHeight: "1.45",
},
".cm-gutters": {
backgroundColor: "rgb(var(--surface-1))",
color: "rgb(var(--fg-muted))",
border: "none",
},
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgb(var(--accent) / 0.3)",
},
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${M} .cm-changedText`]: { background: "none" },
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
// The original, left of a split view: its changed lines are removals.
[`${A} .cm-changedLine`]: { backgroundColor: RED },
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${A} .cm-changedText`]: { background: "none" },
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
"&.cm-editor.cm-editor .cm-collapsedLines": {
color: "rgb(var(--fg-muted))",
background: "rgb(var(--surface-2))",
},
".cm-chunkButtons": { zIndex: "1" },
});
/** The Revert button on a changed block; @codemirror/merge wires the click.
* Between split columns there is room for an arrow only (`→`: left onto right). */
function revertButton(label: string, arrow = false) {
const b = document.createElement("button");
b.type = "button";
b.textContent = arrow ? "→" : label;
b.title = label;
b.setAttribute("aria-label", label);
b.className =
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
return b;
}
export default function DiffEditor({
value,
original,
lang,
layout = "unified",
collapse = false,
label,
revertLabel,
onChange,
onSave,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
original: string;
lang: string;
/** One editor with removals inline, or the original beside the file. */
layout?: "unified" | "split";
/** Fold unchanged stretches away: only the changes and their context. */
collapse?: boolean;
label: string;
/** The text of each changed block's Revert button. */
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
}) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave });
cb.current = { onChange, onSave };
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme];
const editing = [
...common,
highlightActiveLineGutter(),
history(),
drawSelection(),
highlightActiveLine(),
keymap.of([
{
key: "Mod-s",
preventDefault: true,
run: () => {
cb.current.onSave();
return true;
},
},
indentWithTab,
...defaultKeymap,
...historyKeymap,
]),
EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
}),
];
let v: EditorView;
let destroy: () => void;
if (layout === "split") {
const mv = new MergeView({
parent: host.current!,
a: {
doc: original,
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
},
b: { doc: value, extensions: editing },
revertControls: "a-to-b",
renderRevertControl: () => revertButton(revertLabel, true),
collapseUnchanged: folded,
});
v = mv.b;
destroy = () => mv.destroy();
} else {
v = new EditorView({
parent: host.current!,
state: EditorState.create({
doc: value,
extensions: [
...editing,
unifiedMergeView({
original,
syntaxHighlightDeletions: false,
collapseUnchanged: folded,
// Accept would only hide a block; Revert is the one that edits.
mergeControls: (type, action) => {
const b = revertButton(revertLabel);
if (type === "accept") b.hidden = true;
else b.onmousedown = action;
return b;
},
}),
],
}),
});
destroy = () => v.destroy();
}
v.focus();
view.current = v;
return destroy;
// One mount per view: the parent keys this component by file and shape.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => {
const v = view.current;
if (v && v.state.doc.toString() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
}
}, [value]);
return (
<div
ref={host}
data-testid="lane-file-editor"
data-layout={layout}
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
/>
);
}
File diff suppressed because it is too large Load Diff
+21 -41
View File
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
export const ROW_PX = 16; export const ROW_PX = 16;
const OVERSCAN = 20; const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX }; const FIXED: CSSProperties = { height: ROW_PX };
const MIN_ROW: CSSProperties = { minHeight: ROW_PX };
/** /**
* The `[start, end)` item range to render for a scroll position. `scrollTop` * The `[start, end)` item range to render for a scroll position. `scrollTop`
@@ -165,7 +166,7 @@ function Windowed({
} }
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */ /** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) { export function langFor(filePath: string) {
const ext = filePath.split(".").pop() || ""; const ext = filePath.split(".").pop() || "";
return canonicalLang(ext === filePath ? "plain" : ext); return canonicalLang(ext === filePath ? "plain" : ext);
} }
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
return ( return (
<> <>
<Code text={text.slice(0, from)} lang={lang} /> <Code text={text.slice(0, from)} lang={lang} />
<span className="rounded-sm bg-fg-primary/15"> <span className="rounded-sm bg-diff-mod/40">
<Code text={text.slice(from, to)} lang={lang} /> <Code text={text.slice(from, to)} lang={lang} />
</span> </span>
<Code text={text.slice(to)} lang={lang} /> <Code text={text.slice(to)} lang={lang} />
@@ -209,9 +210,10 @@ function emphasis(
while (start < max && del[start] === add[start]) start += 1; while (start < max && del[start] === add[start]) start += 1;
let end = 0; let end = 0;
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1; 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 // No letter or digit in common at either edge (blanks, a `;`, a `}` do not
// highlighting all of it adds noise on top of the row tint. // count): the whole line is the change — a removed line and an added one,
if (start === 0 && end === 0) return null; // not an edit — and highlighting all of it adds noise on top of the row tint.
if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null;
return { return {
del: [start, del.length - end], del: [start, del.length - end],
add: [start, add.length - end], add: [start, add.length - end],
@@ -294,7 +296,8 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
} }
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. /** 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. */ * `style` pins a windowed row's height; an unwindowed row is at least one
* line tall, so a split view's empty filler row keeps its column in step. */
function Row({ function Row({
tint, tint,
style, style,
@@ -310,7 +313,7 @@ function Row({
return ( return (
<div <div
className={`flex w-max min-w-full ${tint}`} className={`flex w-max min-w-full ${tint}`}
style={style} style={style ?? MIN_ROW}
data-diff-item={style ? "" : undefined} data-diff-item={style ? "" : undefined}
data-change-index={anchor} data-change-index={anchor}
> >
@@ -320,15 +323,19 @@ function Row({
} }
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still /** 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). */ * separate from context at a glance on every theme (the /10 fill alone did not).
function rowTint(type: LaneDiffLine["type"] | null) { * Green/red mark a line added or removed whole; blue (`mod`) a line edited in
* place — a -/+ pair that keeps part of its text, its edit emphasised. */
function rowTint(type: LaneDiffLine["type"] | null, mod = false) {
if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]";
if (type === "add") if (type === "add")
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]"; 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))]"; if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
return ""; return "";
} }
function markerClass(type: LaneDiffLine["type"]) { function markerClass(type: LaneDiffLine["type"], mod = false) {
if (mod && type !== "ctx") return "font-bold text-diff-mod";
if (type === "add") return "font-bold text-status-success"; if (type === "add") return "font-bold text-status-success";
if (type === "del") return "font-bold text-status-danger"; if (type === "del") return "font-bold text-status-danger";
return "text-fg-muted"; return "text-fg-muted";
@@ -485,11 +492,11 @@ function UnifiedLine({
}) { }) {
const anchor = useAnchor(start); const anchor = useAnchor(start);
return ( return (
<Row tint={rowTint(line.type)} style={style} anchor={anchor}> <Row tint={rowTint(line.type, !!emph)} style={style} anchor={anchor}>
<Gutter n={line.oldLine} /> <Gutter n={line.oldLine} />
<Gutter n={line.newLine} commentable /> <Gutter n={line.newLine} commentable />
<span className="whitespace-pre px-2"> <span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}> <span className={`select-none ${markerClass(line.type, !!emph)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "} {line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span> </span>
<Code text={line.text} lang={lang} emph={emph} /> <Code text={line.text} lang={lang} emph={emph} />
@@ -558,7 +565,7 @@ function SplitLine({
const line = side === "left" ? row.left : row.right; const line = side === "left" ? row.left : row.right;
const anchor = useAnchor(start); const anchor = useAnchor(start);
return ( return (
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}> <Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
<Gutter <Gutter
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
commentable={side === "right"} commentable={side === "right"}
@@ -941,16 +948,12 @@ export function LaneFileView({
lines, lines,
truncated, truncated,
fill = false, fill = false,
onEditAt,
}: { }: {
path: string; path: string;
lines: string[]; lines: string[];
truncated?: boolean; truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */ /** Take the parent's height instead of the 60vh cap. */
fill?: boolean; fill?: boolean;
/** Set when the file is editable: a click on the text (0-based line, and
* where the view was scrolled) asks to open the editor there. */
onEditAt?: (line: number, scrollTop: number) => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const lang = langFor(path); const lang = langFor(path);
@@ -959,40 +962,17 @@ export function LaneFileView({
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]); const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => ( const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}> <Row key={i} tint="" style={style}>
{/* `contents`: a line marker for the click handler, not a layout box. */}
<span data-ln={i} className="contents">
<Gutter n={i + 1} /> <Gutter n={i + 1} />
<span className="whitespace-pre px-2"> <span className="whitespace-pre px-2">
<Code text={text} lang={lang} /> <Code text={text} lang={lang} />
</span> </span>
</span>
</Row> </Row>
); );
return ( return (
<div <div
data-testid="lane-file-view" data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)} onScroll={(e) => win.onScroll(e.currentTarget)}
tabIndex={onEditAt ? 0 : undefined} className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
onClick={
onEditAt &&
((e) => {
// A click that ends a drag-selection is copying, not asking to edit.
if (window.getSelection()?.toString()) return;
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
})
}
onKeyDown={
onEditAt &&
((e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onEditAt(0, e.currentTarget.scrollTop);
})
}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
onEditAt ? " cursor-text" : ""
}`}
> >
{win.on ? ( {win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}> <Windowed count={lines.length} range={win.range} minWidth={minWidth}>
+133 -57
View File
@@ -1,7 +1,11 @@
/** /**
* @file The top of the Changes panel: the lane's working-copy line (branch, * @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) * head, subject) and the git action bar under it (`LaneGitBar`); plus the
* and the git action bar under it (`LaneGitBar`). * uncommitted counts drawn vertical on the CLOSED Changes rail
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
* by the real total, listing the server's first 20, its "… more files" opening
* the Changes panel — the open panel lists those files itself, so its header
* shows no counts.
* *
* It used to sit in the lane card, a column away from the diff it describes — * 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. * a pull there changed files the Changes panel only noticed on its next poll.
@@ -100,14 +104,20 @@ const GIT_STATUS_STYLE: Record<string, string> = {
function LaneGitFilesPanel({ function LaneGitFilesPanel({
files, files,
hidden, hidden,
onShowAll,
anchor, anchor,
side = "below",
onPointerEnter, onPointerEnter,
onPointerLeave, onPointerLeave,
}: { }: {
files: LaneGitFile[]; files: LaneGitFile[];
hidden: number; hidden: number;
/** Opens the Changes panel, which lists every file the cap left out. */
onShowAll?: () => void;
/** The counts button this panel hangs off. */ /** The counts button this panel hangs off. */
anchor: HTMLElement | null; anchor: HTMLElement | null;
/** `left` beside a chip on the right-edge rail, where below would cover it. */
side?: "below" | "left";
onPointerEnter: () => void; onPointerEnter: () => void;
onPointerLeave: () => void; onPointerLeave: () => void;
}) { }) {
@@ -122,25 +132,33 @@ function LaneGitFilesPanel({
const a = anchor.getBoundingClientRect(); const a = anchor.getBoundingClientRect();
const p = panelRef.current.getBoundingClientRect(); const p = panelRef.current.getBoundingClientRect();
const gap = 4; const gap = 4;
if (side === "left") {
setPos({
top: Math.max(8, Math.min(a.top, window.innerHeight - p.height - 8)),
left: Math.max(8, a.left - p.width - gap),
});
return;
}
const top = const top =
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap; a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
setPos({ setPos({
top: Math.max(8, top), top: Math.max(8, top),
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)), left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
}); });
}, [anchor, files]); }, [anchor, files, side]);
// Totals are over the LISTED files only — the cap means the rest are unknown, // 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 // and a total that silently omits them would be a wrong number rather than a
// partial one. `hidden` says how many are missing. // partial one. `hidden` says how many are missing.
const added = files.reduce((n, f) => n + (f.added ?? 0), 0); const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0); const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
const total = files.length + hidden;
return createPortal( return createPortal(
<div <div
ref={panelRef} ref={panelRef}
data-testid="lane-git-files" data-testid="lane-git-files"
role="group" role="group"
aria-label={t("git.filesHeading", { count: files.length })} aria-label={t("git.filesHeading", { count: total })}
// The pointer has to be able to travel INTO the panel to scroll it, so // 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. // the panel answers the same enter/leave pair the trigger does.
onMouseEnter={onPointerEnter} onMouseEnter={onPointerEnter}
@@ -157,7 +175,7 @@ function LaneGitFilesPanel({
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" 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]"> <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="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
<span className="ml-auto shrink-0 font-mono tabular-nums"> <span className="ml-auto shrink-0 font-mono tabular-nums">
<span className="text-status-success">+{added}</span>{" "} <span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span> <span className="text-status-danger">−{deleted}</span>
@@ -194,16 +212,124 @@ function LaneGitFilesPanel({
); );
})} })}
</ul> </ul>
{hidden > 0 && ( {hidden > 0 &&
(onShowAll ? (
<button
type="button"
data-testid="lane-git-files-more"
onClick={onShowAll}
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
>
{t("git.filesMore", { count: hidden })}
</button>
) : (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted"> <p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })} {t("git.filesMore", { count: hidden })}
</p> </p>
)} ))}
</div>, </div>,
document.body document.body
); );
} }
/**
* The uncommitted counts as a chip that opens the per-file panel on hover,
* focus or click — the closed Changes rail, `vertical`, where nothing else
* lists the files.
*/
export function LaneGitCounts({
git,
vertical = false,
onShowAll,
}: {
git: Extract<LaneGitFacts, { available: true }>;
vertical?: boolean;
/** See `LaneGitFilesPanel`. */
onShowAll?: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [anchor, setAnchor] = 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 closeTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(closeTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
if (git.dirty === 0 && git.untracked === 0) return null;
const label = t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked });
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
}`;
return (
<>
<button
type="button"
ref={setAnchor}
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}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className={`cursor-help ${chip}`}
>
{label}
</button>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
onShowAll={
onShowAll &&
(() => {
setFilesOpen(false);
onShowAll();
})
}
anchor={anchor}
side={vertical ? "left" : "below"}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</>
);
}
/** The counts on the closed Changes rail: its own facts read, polled only
* while the rail is shown (the open panel owns the read otherwise). */
export function LaneGitRailCounts({
laneId,
session,
onShowAll,
}: {
laneId: number;
session?: string;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
const [git] = useLaneGitFacts(laneId, session);
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
}
export function LaneGitHeader({ export function LaneGitHeader({
laneId, laneId,
session, session,
@@ -220,23 +346,6 @@ export function LaneGitHeader({
/** See `LaneGitBar`'s prop of the same name. */ /** See `LaneGitBar`'s prop of the same name. */
onNewLane?: (branch?: string) => void; 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; if (!git?.available) return null;
const lead = ( const lead = (
<> <>
@@ -255,30 +364,6 @@ export function LaneGitHeader({
<span className="shrink-0 text-fg-muted">{git.head}</span> <span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span> <span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div> </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}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
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 ( return (
@@ -291,15 +376,6 @@ export function LaneGitHeader({
onNewLane={onNewLane} onNewLane={onNewLane}
lead={lead} 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> </div>
); );
} }
@@ -26,15 +26,20 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession"; import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList"; import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
/** The workspace of this tool whose folder holds `cwd` most deeply — the /** The workspace of this tool whose folder holds `cwd` most deeply — the
* same path-boundary rule the server binds hook sessions with. */ * same path-boundary rule the server binds hook sessions with. */
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null { export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
let best: Lane | null = null; let best: Lane | null = null;
const target = strip(cwd);
for (const l of lanes) { for (const l of lanes) {
if (l.tool !== tool) continue; if (l.tool !== tool) continue;
const root = l.cwd.replace(/\/+$/, ""); const root = strip(l.cwd);
if (cwd !== root && !cwd.startsWith(root + "/")) continue; if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l; continue;
if (!best || root.length > strip(best.cwd).length) best = l;
} }
return best; return best;
} }
@@ -74,7 +79,7 @@ export function SessionResumeButton({
owningLane(session.cwd, tool, lanes)); owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null; if (!lane || runs === null) return null;
const run = liveRunFor(lane, runs, session.id); const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); const same = (a: string, b: string) => strip(a) === strip(b);
// Its own worktree counts too: the server resumes it there (`claude // Its own worktree counts too: the server resumes it there (`claude
// --worktree` sessions), and refuses one whose worktree is gone. // --worktree` sessions), and refuses one whose worktree is gone.
const resumable = const resumable =
+44 -29
View File
@@ -1,13 +1,13 @@
/** /**
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a * @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW * Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and * session in the first free slot (0–9, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`) — the one place a session starts WITH a * then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell, * prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside * `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's * the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* by default) and one Model chip (`ModelMenu`: the tool's own list or the * by default) and one Model chip (`ModelMenu`: the tool's own list or the
* router's models, plus Effort); under it the quieter choices — AI Router * proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval, * (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it * a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot, * owns and has not archived (`?lane=&archived=0`, with each one's state dot,
@@ -58,7 +58,7 @@ import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs"; import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup"; import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker"; import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select"; import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview"; import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
@@ -82,6 +82,7 @@ export {
startInFreeTabDetailed, startInFreeTabDetailed,
} from "./startSession"; } from "./startSession";
import { startInFreeTab } from "./startSession"; import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
function fmtBytes(n: number | null): string { function fmtBytes(n: number | null): string {
if (!n) return ""; if (!n) return "";
@@ -110,9 +111,8 @@ function HomeBody({
onRenamed, onRenamed,
}: { }: {
lane: Lane; lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. /** Row-menu items the page carries out (its modals); rename is done here. */
* `started` comes with the run it opened. */ onMenuAction: (action: WorkspaceMenuAction) => void;
onMenuAction: (action: WorkspaceMenuAction, runId?: string) => void;
/** Called after a rename, so the page re-reads the lane list. */ /** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void; onRenamed?: () => void;
}) { }) {
@@ -125,27 +125,43 @@ function HomeBody({
const entry = aiTools.byId(tool); const entry = aiTools.byId(tool);
const claude = tool === "claude"; const claude = tool === "claude";
const codex = tool === "codex"; const codex = tool === "codex";
const [prompt, setPrompt] = useState(""); // The half-typed prompt survives leaving the page or the workspace.
const draftKey = `agenthub.workspace.draft.${lane.id}`;
const [prompt, setPrompt] = useState(() => {
try {
return localStorage.getItem(draftKey) ?? "";
} catch {
return "";
}
});
useEffect(() => {
try {
if (prompt) localStorage.setItem(draftKey, prompt);
else localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: the draft just does not survive */
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]); const [images, setImages] = useState<PromptImage[]>([]);
const [router, setRouter] = useSessionRouter(); const [proxy, setProxy] = useSessionProxy();
const routers = useAiRouters(); const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as RouterPicker). // Claude and Codex, also before the catalog has loaded (as ProxyPicker).
const routerCapable = entry?.routerCapable ?? (claude || codex); const proxyCapable = entry?.proxyCapable ?? (claude || codex);
// A profile picked for a tool that can use one; its models replace the tool's own. // A profile picked for a tool that can use one; its models replace the tool's own.
const profile = routerCapable const profile = proxyCapable
? (routers?.find((r) => r.name === router && !r.builtin) ?? null) ? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
: null; : null;
const routerModels: ModelChoice[] | undefined = profile const proxyModels: ModelChoice[] | undefined = profile
? [ ? [
...new Set( ...new Set(
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter( [profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
Boolean (m): m is string => !!m
) )
), ),
].map((id) => ({ id, label: id })) ].map((id) => ({ id, label: id }))
: undefined; : undefined;
// The tool's default model (also when the catalog arrives after mount); on // The tool's default model (also when the catalog arrives after mount); on
// a router, the router's own default. A new tool or router drops the pick. // a proxy, the proxy's own default. A new tool or proxy drops the pick.
const defaultModel = profile ? "" : (entry?.defaultModel ?? ""); const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
const [model, setModel] = useState(defaultModel); const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]); useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
@@ -303,7 +319,7 @@ function HomeBody({
// Only a different tool is named: the workspace's own keeps starting // Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings. // even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}), ...(tool !== lane.tool ? { tool } : {}),
...routerArg(routerCapable, router), ...proxyArg(proxyCapable, proxy),
...(hasModel && model ? { model } : {}), ...(hasModel && model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}), ...(claude ? { permissionMode: mode } : {}),
...(codex ? { sandbox, approval } : {}), ...(codex ? { sandbox, approval } : {}),
@@ -517,7 +533,7 @@ function HomeBody({
value={model} value={model}
onChange={setModel} onChange={setModel}
tool={tool} tool={tool}
choices={routerModels} choices={proxyModels}
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})} {...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
/> />
)} )}
@@ -540,12 +556,12 @@ function HomeBody({
data-testid="workspace-home-options" data-testid="workspace-home-options"
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1" className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
> >
<RouterPicker <ProxyPicker
variant="ghost" variant="ghost"
tool={tool} tool={tool}
routers={routers} proxies={proxies}
value={router} value={proxy}
onChange={setRouter} onChange={setProxy}
/> />
{claude && ( {claude && (
<Select<PermissionMode> <Select<PermissionMode>
@@ -957,6 +973,7 @@ function HomeBody({
onChange={(e) => setFolderPath(e.target.value)} onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary" className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/> />
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs"> <button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")} {t("home.add")}
</button> </button>
@@ -1016,10 +1033,10 @@ function HomeBody({
<div <div
role="tablist" role="tablist"
aria-label={t("home.memory")} aria-label={t("home.memory")}
// shrink-0: a horizontal scroller's min-height is 0, so in this // Wraps, so every file is reachable without a horizontal wheel (a
// height-capped column a long file squeezed the row and clipped // hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// the tabs. The panel below is the one part that scrolls. // with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex shrink-0 gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar" className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
> >
{memory.map((f, i) => ( {memory.map((f, i) => (
<button <button
@@ -1124,9 +1141,7 @@ function HomeBody({
x={menuAt.x} x={menuAt.x}
y={menuAt.y} y={menuAt.y}
onClose={() => setMenuAt(null)} onClose={() => setMenuAt(null)}
onAction={(a, runId) => onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
}
/> />
)} )}
</div> </div>
+6 -25
View File
@@ -6,8 +6,8 @@
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on * session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer * right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to * preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row. Only a workspace row's own drag (`LANE_DRAG_TYPE`) is taken — * the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
* a file, a tab or a text dragged over is not a reorder. F2 renames too; the * (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring. * dot's meaning is screen-reader text, and an idle dot is a ring.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -20,7 +20,7 @@ import { isSubmitEnter } from "../../lib/keys";
/** What a workspace row's drag carries: its index in the list. */ /** What a workspace row's drag carries: its index in the list. */
export const LANE_DRAG_TYPE = "application/x-agenthub-lane"; export const LANE_DRAG_TYPE = "application/x-agenthub-lane";
const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) => export const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE); !!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE);
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */ /** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
@@ -78,7 +78,6 @@ export function WorkspaceRow({
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [dropTarget, setDropTarget] = useState(false);
const name = workspaceName(lane); const name = workspaceName(lane);
const openMenu = (e: MouseEvent) => { const openMenu = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -92,34 +91,16 @@ export function WorkspaceRow({
return ( return (
<div <div
data-testid={`workspace-row-${lane.id}`} data-testid={`workspace-row-${lane.id}`}
data-drop-target={dropTarget ? "true" : undefined} data-lane-index={index}
draggable={!renaming} draggable={!renaming}
onDragStart={(e) => { onDragStart={(e) => {
// Firefox starts no drag at all without a setData call. // Firefox starts no drag at all without a setData call.
e.dataTransfer.setData(LANE_DRAG_TYPE, String(index)); e.dataTransfer.setData(LANE_DRAG_TYPE, String(index));
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
}} }}
onDragOver={(e) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
setDropTarget(false);
if (!isLaneDrag(e)) return;
e.preventDefault();
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
if (Number.isInteger(from)) onReorder(from, index);
}}
onContextMenu={openMenu} onContextMenu={openMenu}
className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${ className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${
dropTarget selected
? "bg-accent/20"
: selected
? "bg-accent/10" ? "bg-accent/10"
: lane.needs_action : lane.needs_action
? "bg-status-warning/10 hover:bg-status-warning/20" ? "bg-status-warning/10 hover:bg-status-warning/20"
@@ -179,7 +160,7 @@ export function WorkspaceRow({
> >
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} /> <span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span> <span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] text-fg-primary">{name}</span> <span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
</button> </button>
)} )}
{!renaming && ( {!renaming && (
@@ -1,29 +1,20 @@
/** /**
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at * @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an * the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start (`POST /api/lanes/:id/start`, the free tab the server * existing call: Rename (the row's inline field), New workspace here (the
* picks — then the session opens, `onAction("started", runId)`) / Stop * Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* (`stop`, every tab of the workspace — counted from `GET /api/run` when the
* menu opens, and confirmed: "Stop N sessions?"),
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides * managed lane. The server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position, * what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`. * Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started"; export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
export function WorkspaceRowMenu({ export function WorkspaceRowMenu({
lane, lane,
@@ -36,62 +27,12 @@ export function WorkspaceRowMenu({
x: number; x: number;
y: number; y: number;
onClose: () => void; onClose: () => void;
/** Items the tree itself carries out (rename field, modals, selection); /** Items the tree itself carries out (rename field, modals). */
* `started` comes with the run it opened. */ onAction: (action: WorkspaceMenuAction) => void;
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
// Every live tab of the workspace — slot 0 alone undercounts a split.
const [liveCount, setLiveCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
api.run
.list()
.then(
(r) =>
alive &&
setLiveCount(
(r.items ?? []).filter((x) => x.laneId === lane.id && x.status === "running").length
)
)
.catch(() => undefined);
return () => {
alive = false;
};
}, [lane.id]);
const live = liveCount === null ? !!lane.run_id || lane.status === "running" : liveCount > 0;
const stop = async () => {
onClose();
const n = liveCount ?? 1;
if (
!(await confirmDialog({
title: t("menu.stopTitle", { count: n }),
message: t("menu.stopBody"),
confirmLabel: t("menu.stop"),
destructive: true,
}))
)
return;
try {
await api.lanes.action(lane.id, "stop");
} catch (err) {
toast.error(err);
}
};
const start = async () => {
onClose();
try {
onAction("started", await startInFreeTab(lane.id, { prompt: "" }));
} catch (err) {
toast.error(err);
}
};
return ( return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}> <ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem onSelect={() => void (live ? stop() : start())}>
{live ? t("menu.stop") : t("menu.start")}
</MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem> <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem> <MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem <MenuItem
@@ -11,7 +11,11 @@
* carrying its session id) opens that tmux pane on the Workspace page * carrying its session id) opens that tmux pane on the Workspace page
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose * (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
* session has no row yet — a bare tab before its first prompt — is listed as * session has no row yet — a bare tab before its first prompt — is listed as
* **New session** from the run itself; Ctrl/Cmd+click opens * **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or * it in a split beside the focused pane) — attaching, never starting or
* resuming anything; * resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`, * any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -28,19 +32,23 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch } from "lucide-react"; import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api"; import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types"; import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format"; import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu"; import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6; export const SESSION_LIMIT = 6;
/** Closed Terminal tabs listed under a workspace's sessions. */
export const CLOSED_TERMINALS = 3;
/** /**
* The live pane holding a session: the run the hooks bound it to * The live pane holding a session: the run the hooks bound it to
@@ -58,9 +66,10 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
/** The name of the git worktree a session ran in (`claude --worktree` puts it /** The name of the git worktree a session ran in (`claude --worktree` puts it
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */ * at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
export function sessionWorktreeName(lane: Lane, s: Session): string | null { export function sessionWorktreeName(lane: Lane, s: Session): string | null {
const strip = (p: string) => p.replace(/\/+$/, ""); // Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
const strip = (p: string) => p.replace(/[\\/]+$/, "");
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null; if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null; return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
} }
/** A session row's dot: waiting for the user, failed, held by a live pane, or /** A session row's dot: waiting for the user, failed, held by a live pane, or
@@ -253,12 +262,52 @@ export function WorkspaceSessionList({
const laneRuns = liveRuns.filter( const laneRuns = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell" (r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell"
); );
const liveShells = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool === "shell"
);
// The lane's own session/status/needs-you move on every hook, which is when // The lane's own session/status/needs-you move on every hook, which is when
// a session can appear or change state, and a tab opening or getting its // a session can appear or change state, and a tab opening or getting its
// session is too — re-read then, not on a timer. // session is too — re-read then, not on a timer.
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
.map((r) => `${r.id}=${r.sessionId ?? ""}`) .map((r) => `${r.id}=${r.sessionId ?? ""}`)
.join(",")}`; .join(",")}`;
// A Terminal tab closing is when one joins the history.
const shellKey = liveShells.map((r) => r.id).join(",");
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
useEffect(() => {
if (hidden) return;
let live = true;
api.run
// Live ones come back too (isLive): ask for enough to fill the list anyway.
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
.then(
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
)
.catch(() => undefined);
return () => {
live = false;
};
}, [lane.id, shellKey, hidden]);
const reopenShell = async (split: boolean) => {
try {
const runId = await startInFreeTab(lane.id, { shell: true });
navigate(
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
split ? { state: { split: true } } : undefined
);
} catch (err) {
toast.error(err);
}
};
const closedCtx = useContextMenu<RunHistoryItem>();
const forgetShell = async (r: RunHistoryItem) => {
try {
await api.run.forgetHistory(r.id);
setClosedShells((all) => all.filter((x) => x.id !== r.id));
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey); const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []); const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0; const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -301,11 +350,15 @@ export function WorkspaceSessionList({
const fresh = const fresh =
sessions === null || q sessions === null || q
? [] ? []
: laneRuns.filter( : [
...laneRuns.filter(
(r) => (r) =>
!r.sessionId || !r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId)) (!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
); ),
// Live Terminal tabs: no session row ever, so listed from the run.
...liveShells,
];
const count = sessions === null ? null : shown.length; const count = sessions === null ? null : shown.length;
useEffect(() => { useEffect(() => {
if (count !== null) onMatches?.(count); if (count !== null) onMatches?.(count);
@@ -380,10 +433,12 @@ export function WorkspaceSessionList({
{error} {error}
</p> </p>
); );
if (sessions !== null && shown.length === 0 && fresh.length === 0) { // Not during a search, like the live ones: they have no name to match.
const closed = q ? [] : closedShells;
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
return ( return (
<> <>
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p> <p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
{failure} {failure}
{confirmArchive} {confirmArchive}
</> </>
@@ -391,7 +446,11 @@ export function WorkspaceSessionList({
} }
return ( return (
<> <>
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px"> <ul
data-testid={`workspace-sessions-${lane.id}`}
// A guide line under the workspace's chevron; the rows start past it.
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
>
{fresh.map((r) => ( {fresh.map((r) => (
<li key={r.id}> <li key={r.id}>
<button <button
@@ -405,8 +464,8 @@ export function WorkspaceSessionList({
) )
} }
onContextMenu={(e) => ctx.open(e, r)} onContextMenu={(e) => ctx.open(e, r)}
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`} title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${ className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
r.id === selectedRunId r.id === selectedRunId
? "bg-accent/10 text-fg-primary" ? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -414,11 +473,20 @@ export function WorkspaceSessionList({
> >
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" /> <span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span> <span className="sr-only">{t("tree.state.live")}</span>
<span className="truncate italic">{t("tree.newSession")}</span> <span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt, true) : null}
</span>
{r.tool === "shell" ? (
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
) : (
<AiToolIcon <AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }} tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70" className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/> />
)}
</button> </button>
</li> </li>
))} ))}
@@ -427,7 +495,7 @@ export function WorkspaceSessionList({
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId; const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) { if (renaming === s.id) {
return ( return (
<li key={s.id} className="py-0.5 pl-7 pr-2"> <li key={s.id} className="py-0.5 pr-2">
<input <input
data-testid={`workspace-session-rename-${s.id}`} data-testid={`workspace-session-rename-${s.id}`}
aria-label={t("tree.renameSession")} aria-label={t("tree.renameSession")}
@@ -481,7 +549,7 @@ export function WorkspaceSessionList({
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}` ? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: "" : ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`} }\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${ className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
selected selected
? "bg-accent/10 text-fg-primary" ? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -501,9 +569,12 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted" className="h-3 w-3 shrink-0 text-fg-muted"
/> />
)} )}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at, true)}
</span>
<AiToolIcon <AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }} tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible" className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
/> />
</button> </button>
<button <button
@@ -521,13 +592,49 @@ export function WorkspaceSessionList({
</li> </li>
); );
})} })}
{closed.map((r) => (
<li key={r.id} className="group relative">
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<span
aria-hidden
className="h-1.5 w-1.5 shrink-0 rounded-full border border-fg-muted"
/>
<span className="sr-only">{t("tree.state.idle")}</span>
<span className="truncate">{t("tabs.terminal")}</span>
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at, true)}
</span>
<SquareTerminal
aria-hidden
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
/>
</button>
<button
type="button"
data-testid={`workspace-terminal-forget-${r.id}`}
onClick={() => void forgetShell(r)}
title={t("tree.forgetTerminal")}
aria-label={t("tree.forgetTerminal")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul> </ul>
{more > 0 && !q && ( {more > 0 && !q && (
<button <button
type="button" type="button"
data-testid={`workspace-sessions-more-${lane.id}`} data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => own && setLimit(own.total)} onClick={() => own && setLimit(own.total)}
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary" className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
> >
{t("tree.seeAll", { count: more })} {t("tree.seeAll", { count: more })}
</button> </button>
@@ -544,6 +651,42 @@ export function WorkspaceSessionList({
onClose={ctx.close} onClose={ctx.close}
/> />
)} )}
{closedCtx.menu && (
<ContextMenu
x={closedCtx.menu.x}
y={closedCtx.menu.y}
onClose={closedCtx.close}
testId={`terminal-menu-${closedCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(false);
}}
>
{t("tree.reopenTerminal")}
</MenuItem>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(true);
}}
>
{t("sessionMenu.openSplit")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const item = closedCtx.menu!.item;
closedCtx.close();
void forgetShell(item);
}}
>
{t("tree.forgetTerminal")}
</MenuItem>
</ContextMenu>
)}
{sessionCtx.menu && ( {sessionCtx.menu && (
<SessionMenu <SessionMenu
x={sessionCtx.menu.x} x={sessionCtx.menu.x}
@@ -26,11 +26,12 @@ vi.mock("../../../lib/api", () => ({
worktree: vi.fn(), worktree: vi.fn(),
ensure: vi.fn(), ensure: vi.fn(),
browse: vi.fn(), browse: vi.fn(),
pickPath: vi.fn(),
get: vi.fn(), get: vi.fn(),
profileInit: vi.fn(), profileInit: vi.fn(),
mcpSync: vi.fn(), mcpSync: vi.fn(),
}, },
aiRouters: { list: vi.fn() }, aiProxies: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` }, aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() }, run: { start: vi.fn() },
}, },
@@ -49,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
prompt: { mode: "argv" }, prompt: { mode: "argv" },
resume: false, resume: false,
bypass: false, bypass: false,
routerCapable: false, proxyCapable: false,
install: null, install: null,
update: null, update: null,
modelFlag: false, modelFlag: false,
@@ -61,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
const TOOLS: AiToolsView = { const TOOLS: AiToolsView = {
default: "claude", default: "claude",
tools: [ tools: [
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }), tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }), tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"), tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }), tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }), tool("grok", "Grok", { enabled: false }),
@@ -147,6 +148,7 @@ beforeEach(() => {
vi.mocked(api.lanes.worktree).mockReset(); vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset(); vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset(); vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the // Provisioning finishes instantly by default - tests that care about the
// provisioning-in-progress race override this per-test. // provisioning-in-progress race override this per-test.
vi.mocked(api.lanes.get) vi.mocked(api.lanes.get)
@@ -156,7 +158,7 @@ beforeEach(() => {
.mockReset() .mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" }); .mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] }); vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.aiRouters.list).mockReset(); vi.mocked(api.aiProxies.list).mockReset();
}); });
describe("AddLaneModal — picking an existing branch (issue #34)", () => { describe("AddLaneModal — picking an existing branch (issue #34)", () => {
@@ -549,7 +551,45 @@ describe("AddLaneModal — repo mode (adopt)", () => {
}); });
describe("AddLaneModal — folder browse", () => { describe("AddLaneModal — folder browse", () => {
const noNativePicker = () =>
vi
.mocked(api.lanes.pickPath)
.mockRejectedValue(Object.assign(new Error("no picker"), { code: "ENODIALOG" }));
it("fills the path field from the OS's own picker", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await waitFor(() =>
expect(document.getElementById("add-lane-repo")).toHaveValue("/Users/tester/app")
);
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("falls back to the in-app browser when no native picker is available", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
const browser = await screen.findByRole("dialog", { name: "Browse for a folder" });
// Its backdrop closes only the browser, never the modal behind it.
await user.click(browser.parentElement!);
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => { it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({ vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester", path: "/Users/tester",
parent: "/Users", parent: "/Users",
@@ -566,6 +606,7 @@ describe("AddLaneModal — folder browse", () => {
}); });
it("Escape closes only the folder browser, not the whole modal", async () => { it("Escape closes only the folder browser, not the whole modal", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({ vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester", path: "/Users/tester",
parent: null, parent: null,
@@ -598,12 +639,12 @@ describe("AddLaneModal — create/update readme", () => {
}); });
}); });
describe("AddLaneModal — no router here", () => { describe("AddLaneModal — no proxy here", () => {
it("asks for no AI Router: a new session picks its own", async () => { it("asks for no AI Proxy: a new session picks its own", async () => {
renderModal({}); renderModal({});
await screen.findByRole("button", { name: /Claude Code.*Default/ }); await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull(); expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
expect(api.aiRouters.list).not.toHaveBeenCalled(); expect(api.aiProxies.list).not.toHaveBeenCalled();
}); });
}); });
@@ -0,0 +1,93 @@
/**
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
* count as edited in place (blue) rather than added or removed whole, and the
* editor's own diff classes on a real mount.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import DiffEditor, { keepsText } from "../DiffEditor";
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
Range.prototype.getClientRects ??= () => EMPTY;
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
describe("keepsText", () => {
it("is false for a line whose every non-blank character changed", () => {
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
expect(keepsText("", 0, [])).toBe(false);
// Only a `;` survived: not the same line.
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
});
it("is true when some text outside the changes survives", () => {
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
});
});
describe("DiffEditor", () => {
it("marks a whole new line green and a line edited in place blue", async () => {
const { container } = render(
<DiffEditor
value={"keep\nconst x = 2;\nbrand new"}
original={"keep\nconst x = 1;"}
lang="ts"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
await waitFor(() =>
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
);
});
it("side by side: the original read-only on the left, its edited line blue", async () => {
const { container } = render(
<DiffEditor
layout="split"
value={"keep\nconst x = 2;"}
original={"keep\nconst x = 1;"}
lang="ts"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
expect(left).toHaveAttribute("contenteditable", "false");
expect(right).toHaveAttribute("contenteditable", "true");
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
// The revert column is drawn in CodeMirror's next measure pass, not at mount.
await waitFor(() =>
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert")
);
});
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
const { container } = render(
<DiffEditor
collapse
value={`${same}\nchanged`}
original={`${same}\noriginal`}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
});
});
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
locks: { locks: {
list: vi.fn(), list: vi.fn(),
}, },
aiRouters: { aiProxies: {
list: vi.fn(), list: vi.fn(),
}, },
run: { run: {
@@ -45,10 +45,10 @@ beforeEach(() => {
// stack, so the runtime strip and its button stay absent unless a test opts in. // 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).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
// Only the built-in profile by default: a user who never opened the AI Router // Only the built-in profile by default: a user who never opened the AI Proxy
// tab has nothing to switch between, so the row stays out of every other test. // tab has nothing to switch between, so the row stays out of every other test.
vi.mocked(api.aiRouters.list).mockReset(); vi.mocked(api.aiProxies.list).mockReset();
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] }); vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
}); });
vi.mocked(api.locks.list).mockReset(); vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] }); vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
@@ -13,13 +13,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react"; import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel"; import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm"; import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types"; import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here. // The themed confirm needs its host (App); answer it here.
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
vi.mock("../DiffEditor", () => ({
default: (p: {
value: string;
original: string;
layout?: string;
collapse?: boolean;
label: string;
onChange: (v: string) => void;
onSave: () => void;
}) => (
<textarea
data-testid="lane-file-editor"
data-original={p.original}
data-layout={p.layout}
data-collapse={String(!!p.collapse)}
aria-label={p.label}
autoFocus
value={p.value}
onChange={(e) => p.onChange(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
p.onSave();
}
}}
/>
),
}));
vi.mock("../../../lib/confirm", () => ({ vi.mock("../../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true), confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true), confirmDialog: vi.fn(async () => true),
@@ -32,8 +61,11 @@ vi.mock("../../../lib/api", () => ({
gitBranches: vi.fn(), gitBranches: vi.fn(),
changes: vi.fn(), changes: vi.fn(),
gitAction: vi.fn(), gitAction: vi.fn(),
suggestCommitMessage: vi.fn(),
gitLog: vi.fn(), gitLog: vi.fn(),
gitFile: vi.fn(), gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
`/api/lanes/${id}/git/raw?path=${encodeURIComponent(path)}&rev=${rev}`,
gitSaveFile: vi.fn(), gitSaveFile: vi.fn(),
gitLs: vi.fn(), gitLs: vi.fn(),
gitTree: vi.fn(), gitTree: vi.fn(),
@@ -88,14 +120,20 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
}; };
} }
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
}
/** The overview row's discard button for one path. */ /** The overview row's discard button for one path. */
function discardOf(path: string) { function discardOf(path: string) {
return within(screen.getByText(path).closest("li")!).getByRole("button", { return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
name: "Discard this file's changes", name: "Discard this file's changes",
}); });
} }
beforeEach(() => { beforeEach(() => {
window.localStorage.removeItem("agenthub.changes.neverStage.7");
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false }); vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset(); vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes()); vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -111,6 +149,7 @@ beforeEach(() => {
author: "t", author: "t",
date: "2026-09-22T01:00:00Z", date: "2026-09-22T01:00:00Z",
refs: ["HEAD -> feat/thing"], refs: ["HEAD -> feat/thing"],
parents: [],
}, },
], ],
}); });
@@ -138,8 +177,8 @@ beforeEach(() => {
}); });
}); });
/** The Workspace right panel's Files/Changes activity-bar buttons, around the /** The panel as the Workspace renders it: Files and Changes are its own mode
* panel — the tab is the Workspace's state, passed in as `tab`. */ * buttons now, no activity-bar tab around it. */
function Tabbed({ function Tabbed({
laneId, laneId,
onDirtyChange, onDirtyChange,
@@ -147,18 +186,7 @@ function Tabbed({
laneId: number; laneId: number;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const [tab, setTab] = useState<"files" | "changes">("changes"); return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
} }
describe("LaneChangesPanel", () => { describe("LaneChangesPanel", () => {
@@ -167,7 +195,7 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument(); expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" }); expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument(); expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByText("+12")).toBeInTheDocument(); expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.queryByText(/new line/)).toBeNull(); expect(screen.queryByText(/new line/)).toBeNull();
}); });
@@ -192,7 +220,7 @@ describe("LaneChangesPanel", () => {
], ],
}) })
); );
await user.click(screen.getByText("server/lib/git-ops.js")); await user.click(screen.getByTitle("server/lib/git-ops.js"));
await waitFor(() => await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, { expect(api.lanes.changes).toHaveBeenCalledWith(7, {
@@ -201,6 +229,7 @@ describe("LaneChangesPanel", () => {
context: 3, context: 3,
}) })
); );
await toDiff(user);
// The highlighter splits a line into one span per token, so assert on the // The highlighter splits a line into one span per token, so assert on the
// rendered text of the whole view rather than on a single node. // rendered text of the whole view rather than on a single node.
const view = await screen.findByTestId("lane-diff-view"); const view = await screen.findByTestId("lane-diff-view");
@@ -256,6 +285,65 @@ describe("LaneChangesPanel", () => {
}); });
}); });
it("keeps a never-stage file out of Stage all, without telling git", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
await user.click(keep!); // the first row: server/lib/git-ops.js
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
"server/lib/git-ops.js",
]);
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
// And back: the row's button now offers the way out.
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
});
it("draws a never-stage file orange in the Files tree", async () => {
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
],
truncated: false,
});
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
expect(await row("notes.txt")).toHaveClass("text-kept");
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
expect(await row("a.txt")).toHaveClass("text-status-warning");
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
// `server/lib` holds nothing but `lib`: one compacted row.
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => { it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({ vi.mocked(api.lanes.git).mockResolvedValue({
available: true, available: true,
@@ -339,16 +427,26 @@ describe("LaneChangesPanel", () => {
}); });
}); });
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => { it("lists staged files apart from the changes the next commit leaves behind", async () => {
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview"); const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement; const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement; const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument(); expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull(); expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument(); expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument(); expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByText("docs/API.md")).toBeNull(); expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument(); expect(screen.getByText("Staged Changes")).toBeInTheDocument();
}); });
@@ -372,15 +470,41 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar"); const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md")); await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-view"); await screen.findByTestId("lane-file-editor");
await user.click(screen.getByTestId("lane-file-preview")); await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-view")).toBeNull(); expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview")); await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello"); expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
}); });
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => { it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "logo.png", path: "logo.png", dir: false, status: "" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "logo.png",
rev: "work",
binary: true,
truncated: false,
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("logo.png"));
const img = within(await screen.findByTestId("lane-file-image")).getByRole("img");
expect(img).toHaveAttribute("src", "/api/lanes/7/git/raw?path=logo.png&rev=work");
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.queryByTestId("lane-file-noedit")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
path: "", path: "",
@@ -405,29 +529,29 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1); expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts")); await user.click(await within(sidebar).findByText("main.ts"));
// Opening a file only shows it: no editor until the text itself is clicked. // Opening a file is editing it, diffed against its HEAD text.
const view = await screen.findByTestId("lane-file-view");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
await user.click(view);
const editor = await screen.findByTestId("lane-file-editor"); const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;"); expect(editor).toHaveValue("const whole = 1;");
expect(editor).toHaveFocus(); expect(editor).toHaveFocus();
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.getByTestId("lane-file-save")).toBeDisabled(); expect(screen.getByTestId("lane-file-save")).toBeDisabled();
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
await user.type(editor, "x"); await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled(); expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel asks before dropping an edit: "keep" keeps it… // Cancel asks before dropping an edit: "keep" keeps it…
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false); vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByTestId("lane-file-cancel"));
expect(confirmDiscard).toHaveBeenCalledTimes(1); expect(confirmDiscard).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x"); expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" drops it and goes back to reading. // …and "discard" reverts it, still editing.
await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByTestId("lane-file-cancel"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;"); expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.getByTestId("lane-file-save")).toBeDisabled();
}); });
it("at full width opens a changed file from the overview for reading, not editing", async () => { it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({ vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true, available: true,
path: "server/lib/git-ops.js", path: "server/lib/git-ops.js",
@@ -440,32 +564,41 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview"); const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js")); await user.click(within(list).getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a"); await screen.findByRole("button", { name: "Changes", pressed: true });
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); // Changes is the editor folded to its changes, side by side at full width.
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("a");
expect(editor).toHaveAttribute("data-collapse", "true");
expect(editor).toHaveAttribute("data-layout", "split");
await user.type(editor, "b");
// Whole file unfolds the same edit — no prompt, nothing dropped.
vi.mocked(confirmDiscard).mockClear();
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
const whole = await screen.findByTestId("lane-file-editor");
expect(whole).toHaveAttribute("data-collapse", "false");
expect(whole).toHaveValue("ab");
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
expect(confirmDiscard).not.toHaveBeenCalled();
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
}); });
it("does not enter the editor when a click only ends a text selection", async () => { it("opens diffs unified at the narrow width", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({ vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true, available: true,
path: "server/lib/git-ops.js", path: "server/lib/git-ops.js",
rev: "work", rev: "work",
binary: false, binary: false,
truncated: false, truncated: false,
lines: ["copy me"], lines: ["a"],
sha: "b".repeat(40), sha: "b".repeat(40),
}); });
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview"); const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js")); await user.click(within(list).getByTitle("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view"); expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "copy",
} as unknown as Selection);
await user.click(view);
spy.mockRestore();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
}); });
it("needs two clicks to discard, and only the second one reaches the server", async () => { it("needs two clicks to discard, and only the second one reaches the server", async () => {
@@ -489,7 +622,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview"); const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByText("server/lib/git-ops.js"); const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row }); const right = () => user.pointer({ keys: "[MouseRight]", target: row });
await right(); await right();
@@ -549,9 +682,9 @@ describe("LaneChangesPanel", () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes()); vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms. // A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" })); await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull()); await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" })); await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js"); await screen.findByTitle("server/lib/git-ops.js");
expect( expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" }) screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull(); ).toBeNull();
@@ -584,7 +717,7 @@ describe("LaneChangesPanel", () => {
resolveOld(changes()); resolveOld(changes());
await new Promise((r) => setTimeout(r, 20)); await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument(); expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull(); expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
}); });
it("opens a commit from history as a commit-range review", async () => { it("opens a commit from history as a commit-range review", async () => {
@@ -593,6 +726,7 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" })); await user.click(screen.getByRole("button", { name: "History" }));
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
await user.click(await screen.findByText("feat: the commit")); await user.click(await screen.findByText("feat: the commit"));
await waitFor(() => await waitFor(() =>
@@ -738,7 +872,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" })); await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main"); await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts")); await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
const editor = await screen.findByTestId("lane-file-editor"); const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;"); expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor); await user.clear(editor);
@@ -751,36 +884,9 @@ describe("LaneChangesPanel", () => {
"const whole = 2;", "const whole = 2;",
"b".repeat(40) "b".repeat(40)
); );
// Back to reading, showing what was saved. // Still editing, on what was saved: nothing left to save.
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;"); await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
}); });
it("says why a file cannot be edited instead of offering a dead Edit button", async () => { it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
@@ -824,7 +930,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" })); await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main"); await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts")); await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "y"); await user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save")); await user.click(screen.getByTestId("lane-file-save"));
@@ -864,7 +969,8 @@ describe("LaneChangesPanel", () => {
], ],
}) })
); );
await user.click(screen.getByText("server/lib/git-ops.js")); await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified"); expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" })); await user.click(screen.getByRole("button", { name: "Side-by-side" }));
@@ -875,7 +981,8 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js")); await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
await screen.findByRole("button", { name: "Show more surrounding lines" }); await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an // Collapse is dead at the tightest step, and live once expanded — an
@@ -905,8 +1012,8 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js")); await user.click(screen.getByTitle("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/); await toDiff(user);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" })); await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() => await waitFor(() =>
@@ -967,11 +1074,10 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull(); expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
}); });
it("leaves Files out of the mode row: it is the right panel's own tab", async () => { it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
expect(screen.queryByRole("button", { name: "Files" })).toBeNull(); for (const name of ["Changes", "Files", "Review", "History"]) {
for (const name of ["Overview", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument(); expect(screen.getByRole("button", { name })).toBeInTheDocument();
} }
}); });
@@ -1323,9 +1429,9 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit"); await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js"); const before = screen.getByTitle("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x"); await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before); expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
}); });
it("expands all folders with ONE request, not one per folder", async () => { it("expands all folders with ONE request, not one per folder", async () => {
@@ -1390,7 +1496,7 @@ describe("LaneChangesPanel", () => {
lane7Reads lane7Reads
); );
expect(screen.getByText("lane8.txt")).toBeInTheDocument(); expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull(); expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
}); });
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => { it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto"); expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
}); });
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
// Two lines removed, none added: the right column's two rows are fillers.
const file = fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
]);
render(<LaneDiffView file={file} layout="split" />);
const fillers = [
...screen
.getByTestId("lane-diff-split-right")
.querySelectorAll<HTMLElement>("div.flex.w-max"),
];
expect(fillers).toHaveLength(2);
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
});
it("caps a review diff only when it windows; the single-file view fills its parent", () => { 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 // Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a // scroller (nested 60vh boxes made hunk navigation land nowhere); a
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
} }
describe("LaneDiffView — row styling", () => { describe("LaneDiffView — row styling", () => {
it("tints add/del rows with their status fill and left bar; context stays plain", () => { // A line added or removed whole: nothing in common with its pair.
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />); const WHOLE = fileWith([
const add = rowOf(container, "x = 2"); { type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
const del = rowOf(container, "x = 1"); { type: "del", oldLine: 2, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
]);
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
const add = rowOf(container, "xyz");
const del = rowOf(container, "abc");
const ctx = rowOf(container, "keep()"); const ctx = rowOf(container, "keep()");
expect(add.className).toContain("bg-status-success/20"); expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success"); expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20"); expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger"); expect(del.className).toContain("--status-danger");
expect(ctx.className).not.toMatch(/status-(success|danger)/); expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
});
it("tints a line edited in place blue on both sides", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
for (const text of ["x = 1", "x = 2"]) {
const row = rowOf(container, text);
expect(row.className).toContain("bg-diff-mod/20");
expect(row.className).not.toMatch(/status-(success|danger)/);
}
}); });
it("colours the +/- marker bold in its status colour, context marker muted", () => { it("colours the +/- marker bold in its status colour, context marker muted", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />); const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
const marker = (row: HTMLElement) => const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!; row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "x = 2")); const plus = marker(rowOf(container, "xyz"));
const minus = marker(rowOf(container, "x = 1")); const minus = marker(rowOf(container, "abc"));
const ctx = marker(rowOf(container, "keep()")); const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+"); expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold"); expect(plus.className).toContain("font-bold");
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
it("emphasises only the changed span of a paired del/add", () => { it("emphasises only the changed span of a paired del/add", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />); const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map( const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
(e) => e.textContent (e) => e.textContent
); );
expect(emph).toEqual(["1", "2"]); expect(emph).toEqual(["1", "2"]);
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
layout="unified" layout="unified"
/> />
); );
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull(); expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
}); });
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => { it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
@@ -1,7 +1,8 @@
/** /**
* @file LaneGitHeader.test.tsx * @file LaneGitHeader.test.tsx
* @description Pins the Changes panel's working-copy line: the live branch, * @description Pins the Changes panel's working-copy line: the live branch,
* head, subject and uncommitted counts, the per-file hover panel behind the * head, subject and uncommitted counts (no hover there — the panel lists the
* files below), the per-file hover panel behind the closed rail's vertical
* counts, that a lane switch drops the previous lane's facts at once, and that * counts, that a lane switch drops the previous lane's facts at once, and that
* an unreadable repo renders nothing rather than an error. * an unreadable repo renders nothing rather than an error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -9,7 +10,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader"; import { LaneGitHeader, LaneGitRailCounts, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
@@ -48,7 +49,7 @@ describe("LaneGitHeader", () => {
behind: null, behind: null,
}; };
it("renders the live branch, head, subject and uncommitted counts", async () => { it("renders the live branch, head and subject", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />); render(<Harness laneId={1} />);
@@ -56,7 +57,6 @@ describe("LaneGitHeader", () => {
expect(block.textContent).toContain("feat/rename-metric"); expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a"); expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form"); 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 () => { it("drops the previous lane's facts the moment the lane changes", async () => {
@@ -70,9 +70,9 @@ describe("LaneGitHeader", () => {
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull(); expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
}); });
it("names the files behind the counts on hover, with each one's line delta", async () => { it("names the files behind the rail's counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />); render(<LaneGitRailCounts laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts"); const counts = await screen.findByTestId("lane-git-counts");
// Hidden until asked for: the row is a one-line summary until you hover. // Hidden until asked for: the row is a one-line summary until you hover.
@@ -98,7 +98,7 @@ describe("LaneGitHeader", () => {
it("stays open while the pointer is inside the panel", async () => { it("stays open while the pointer is inside the panel", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />); render(<LaneGitRailCounts laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts"); const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts); fireEvent.mouseEnter(counts);
@@ -115,12 +115,37 @@ describe("LaneGitHeader", () => {
it("says how many files the server-side cap left out", async () => { 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. // 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 }); vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
render(<Harness laneId={1} />); render(<LaneGitRailCounts laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts")); fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more"); expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
}); });
it("opens the Changes panel from the file list's “more files”", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
const onShowAll = vi.fn();
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
fireEvent.click(screen.getByTestId("lane-git-files-more"));
expect(onShowAll).toHaveBeenCalledOnce();
expect(screen.queryByTestId("lane-git-files")).toBeNull();
});
it("shows no counts in the open panel's header: the list below already does", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
await screen.findByTestId("lane-git");
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
});
it("draws the rail's counts vertical", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneGitRailCounts laneId={1} />);
expect((await screen.findByTestId("lane-git-counts")).className).toContain("vertical-rl");
});
it("omits the uncommitted line when the tree is clean", async () => { it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 }); vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<Harness laneId={1} />); render(<Harness laneId={1} />);
@@ -221,7 +221,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2"); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
}); });
it("says so when the server finds all four panes busy", async () => { it("says so when the server finds all ten sessions in use", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce( vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" }) Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
); );
@@ -229,7 +229,7 @@ describe("SessionResumeButton", () => {
renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" })); await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" })); await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes"); expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
}); });
it("opens the pane the server names when the session is already live there", async () => { it("opens the pane the server names when the session is already live there", async () => {
@@ -2,8 +2,8 @@
* @file WorkspaceHome.test.tsx * @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free * @description The workspace home: a new session starts in the first free
* pane slot and opens it, on the AI tool picked for it (the workspace's by * pane slot and opens it, on the AI tool picked for it (the workspace's by
* default), AI and Model in the box and AI Router / Permission / Worktree * default), AI and Model in the box and AI Proxy / Permission / Worktree
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the * under it (the proxy's models on a proxy), all-busy is refused, Recents open a live pane or the
* transcript (never a resume), instructions save, context files upload and * transcript (never a resume), instructions save, context files upload and
* remove, a folder is added with the repo's untracked worktrees suggested * remove, a folder is added with the repo's untracked worktrees suggested
* (minus hidden sources), an unsupported tool is said so, and memory opens * (minus hidden sources), an unsupported tool is said so, and memory opens
@@ -117,7 +117,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
})), })),
}, },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" }, aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
aiRouters: { list: vi.fn(async () => ({ items: [] })) }, aiProxies: { list: vi.fn(async () => ({ items: [] })) },
}, },
}; };
}); });
@@ -193,8 +193,11 @@ describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => { it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0); expect(freeSlot(1, [])).toBe(0);
expect(freeSlot(1, runs)).toBe(1); expect(freeSlot(1, runs)).toBe(1);
const all = [0, 1, 2, 3].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle); const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
(slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle
);
expect(freeSlot(1, all)).toBeNull(); expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
expect(freeSlot(2, all)).toBe(0); expect(freeSlot(2, all)).toBe(0);
expect(paneRunId(4, 0)).toBe("agenthub-lane-4"); expect(paneRunId(4, 0)).toBe("agenthub-lane-4");
expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2"); expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2");
@@ -221,7 +224,24 @@ describe("WorkspaceHome", () => {
); );
}); });
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => { it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
const user = userEvent.setup();
const first = renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
first.unmount();
const other = renderHome(lane({ id: 2 }));
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
other.unmount();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
renderHome(); renderHome();
await screen.findByTestId("workspace-recent-s-live"); await screen.findByTestId("workspace-recent-s-live");
const labels = (id: string) => const labels = (id: string) =>
@@ -232,7 +252,7 @@ describe("WorkspaceHome", () => {
].map((x) => x.getAttribute("aria-label")); ].map((x) => x.getAttribute("aria-label"));
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]); expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
expect(labels("workspace-home-options")).toEqual([ expect(labels("workspace-home-options")).toEqual([
"AI Router", "AI Proxy",
"Permission mode", "Permission mode",
expect.stringContaining("own git worktree"), expect.stringContaining("own git worktree"),
]); ]);
@@ -243,8 +263,8 @@ describe("WorkspaceHome", () => {
vi.mocked(api.aiTools.list).mockResolvedValue({ vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude", default: "claude",
tools: [ tools: [
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true }, { id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true }, { id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true }, { id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
].map((x) => ({ installed: true, enabled: true, ...x })), ].map((x) => ({ installed: true, enabled: true, ...x })),
} as never); } as never);
@@ -269,9 +289,9 @@ describe("WorkspaceHome", () => {
__resetAiTools(); __resetAiTools();
}); });
it("offers the router's models on a router, and sends the router", async () => { it("offers the proxy's models on a proxy, and sends the proxy", async () => {
localStorage.setItem("agenthub.session.router", "glm"); localStorage.setItem("agenthub.session.proxy", "glm");
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({ vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
items: [ items: [
{ name: "native", label: "Native", builtin: true, models: {} }, { name: "native", label: "Native", builtin: true, models: {} },
{ {
@@ -296,7 +316,7 @@ describe("WorkspaceHome", () => {
expect(api.lanes.action).toHaveBeenCalledWith( expect(api.lanes.action).toHaveBeenCalledWith(
1, 1,
"start", "start",
expect.objectContaining({ router: "glm" }) expect.objectContaining({ proxy: "glm" })
); );
}); });
@@ -318,7 +338,7 @@ describe("WorkspaceHome", () => {
}); });
}); });
it("says so when the server finds all four panes busy", async () => { it("says so when the server finds all ten sessions in use", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce( vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" }) Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
); );
@@ -327,7 +347,7 @@ describe("WorkspaceHome", () => {
await screen.findByTestId("workspace-recent-s-old"); await screen.findByTestId("workspace-recent-s-old");
await user.type(screen.getByLabelText("How can I help you today?"), "go"); await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start")); await user.click(screen.getByTestId("workspace-home-start"));
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes"); expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
}); });
it("starts nothing on an empty prompt, and only once on a double Enter", async () => { it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
@@ -0,0 +1,33 @@
/**
* @file Tests that a session finds its workspace and its `claude --worktree`
* name by folder on Windows paths (`D:\…`) as well as POSIX ones — the
* transcript page's Resume/Fork/Open pane hid on Windows when they matched
* on "/" only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import type { Lane, Session } from "../../../lib/types";
import { owningLane } from "../SessionResumeButton";
import { sessionWorktreeName } from "../WorkspaceSessionList";
const lane = (id: number, cwd: string) => ({ id, cwd, tool: "claude" }) as Lane;
describe("session paths", () => {
it("finds the deepest owning workspace with either separator", () => {
const lanes = [lane(1, "D:\\Project"), lane(2, "D:\\Project\\Hobby\\"), lane(3, "/home/u/app")];
expect(owningLane("D:\\Project\\Hobby\\tri-vi-portfolio", "claude", lanes)?.id).toBe(2);
expect(owningLane("D:\\Project\\Hobby", "claude", lanes)?.id).toBe(2);
expect(owningLane("D:\\ProjectX", "claude", lanes)).toBeNull();
expect(owningLane("/home/u/app/src", "claude", lanes)?.id).toBe(3);
});
it("reads a worktree name from a Windows or POSIX session folder", () => {
const at = (cwd: string) => ({ cwd }) as Session;
expect(
sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\.claude\\worktrees\\fix-a"))
).toBe("fix-a");
expect(sessionWorktreeName(lane(1, "/r"), at("/r/.claude/worktrees/fix-b/"))).toBe("fix-b");
expect(sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\"))).toBeNull();
});
});
+7 -5
View File
@@ -1,14 +1,15 @@
/** /**
* @file startSession.ts — starting a session in a workspace's first free tab * @file startSession.ts — starting a session in a workspace's first free tab
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot * (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu * helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's session
* can start a session without pulling the whole home (and react-markdown) into * list can use them without pulling the whole home (and react-markdown) into
* the main bundle. * the main bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import i18n from "../../i18n"; import i18n from "../../i18n";
import { api, type RunHandle } from "../../lib/api"; import { api, type RunHandle } from "../../lib/api";
import { MAX_SLOTS } from "../../lib/paneLayout";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */ /** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string { export function paneRunId(laneId: number, slot: number): string {
@@ -31,7 +32,7 @@ export async function startInFreeTab(
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */ /** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
export function laneOfRun(runId: string): number | null { export function laneOfRun(runId: string): number | null {
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId); const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} }
@@ -54,10 +55,11 @@ export async function startInFreeTabDetailed(
} }
} }
/** The first pane slot with no live run, or null when all four are busy. */ /** The first pane slot with no live run, or null when all ten are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null { export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set( const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0) runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
); );
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null; for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
return null;
} }
+232 -84
View File
@@ -1,31 +1,38 @@
/** /**
* @file ConsoleTabs.tsx * @file ConsoleTabs.tsx
* @description The Workspace session view's tab bar: one tab per live tmux * @description The Workspace session view's tab bar. Every tab is a GROUP of
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's * one to four split panes (`PaneGroups`, `lib/paneLayout.ts`), each pane a
* `?run=`. A tab is named by its session (`sessions.name`, double-click or * live tmux slot of the workspace (0–9, `GET /api/run`); the active tab is the
* Rename to change it — `PATCH /api/sessions/:id`), else the prompt it started * one on screen. A one-pane tab is named by its session (`sessions.name`,
* on, else its tool and number, and carries a state dot (waiting for you / * double-click or Rename to change it — `PATCH /api/sessions/:id`), else the
* error / running, with screen-reader text). A tab's X stops its session * prompt it started on, else its tool and number; a combo tab shows every
* (`DELETE /api/run/:id`) — after a confirm for an AI tab, since it may be * pane's icon and name. A tab carries a state dot (waiting for you / error /
* mid-turn; a shell closes at once. Arrow keys, Home and End move between tabs * running — a combo shows the most urgent, with screen-reader text). A tab's
* (one tab stop, APG). Errors are toasts. The `+` menu opens a new tab in the * X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
* free slot the server picks (`slot: "auto"`) — a plain shell * one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI * keys, Home and End move between tabs (one tab stop, APG). Errors are
* tool started bare on its default model, no prompt (a prompt, router or * toasts. The `+` menu opens a new tab in the free slot the server picks
* worktree is still the workspace home's prompt box) — plus a link to * (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The * or an installed, enabled AI tool started bare on its default model, no
* bar is the console's only header: Home (the workspace's home) at its left * prompt (a prompt, proxy or worktree is still the workspace home's prompt
* end, `actions` (the single pane's split menus) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click * box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags * (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked. A * workspace's home) at its left end, `actions` (the single pane's split menus)
* right-click on a tab opens the same actions as a menu (`ContextMenu`). * at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
* a `+` item) brings it into the tab on screen, split to the right, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { import {
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type DragEvent,
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -42,7 +49,9 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession"; import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { RunIcon, startPaneDrag } from "./PaneGrid"; import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>; type AiToolsState = ReturnType<typeof useAiTools>;
@@ -113,11 +122,27 @@ export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
}; };
} }
/** Drag data of a whole tab (its index), which only reorders the bar — a
* one-pane tab drags as its pane instead (`PANE_DRAG_TYPE`). */
const TAB_DRAG_TYPE = "application/x-agenthub-tab";
/** One tab: a group's index, slots and the runs the list knows of them. */
interface Tab {
index: number;
key: string;
slots: number[];
runs: RunHandle[];
/** The group's focused pane's run. */
focus: RunHandle;
}
const URGENCY: RunState[] = ["awaiting", "error", "running"];
export function ConsoleTabs({ export function ConsoleTabs({
lane, lane,
tabs, tabs: runs,
activeRunId, groups,
shownRunIds = [], onGroupsChange,
onOpen, onOpen,
onClosed, onClosed,
onHome, onHome,
@@ -128,15 +153,16 @@ export function ConsoleTabs({
lane: Lane; lane: Lane;
/** The workspace's live runs, in slot order. */ /** The workspace's live runs, in slot order. */
tabs: RunHandle[]; tabs: RunHandle[];
/** The tabs: groups of panes, the active one on screen. */
groups: PaneGroups;
/** Reorders the tabs or takes a pane out into a tab of its own. */
onGroupsChange: GroupsUpdate;
/** The sessions the tabs hold, by id: their names and states. */ /** The sessions the tabs hold, by id: their names and states. */
sessions?: Map<string, Session>; sessions?: Map<string, Session>;
/** A tab's session was renamed: re-read the sessions. */ /** A tab's session was renamed: re-read the sessions. */
onRenamed?: () => void; onRenamed?: () => void;
/** The focused pane's run. */ /** Shows the tab holding `runId`; `split` (Ctrl/Cmd+click) brings it into
activeRunId: string | null; * the tab on screen instead. */
/** Runs the other panes show. */
shownRunIds?: string[];
/** `split`: Ctrl/Cmd+click — open beside the focused pane. */
onOpen: (runId: string, split?: boolean) => void; onOpen: (runId: string, split?: boolean) => void;
/** A tab's session was stopped. */ /** A tab's session was stopped. */
onClosed: (runId: string) => void; onClosed: (runId: string) => void;
@@ -154,7 +180,78 @@ export function ConsoleTabs({
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const tabRefs = useRef(new Map<string, HTMLButtonElement>()); const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<RunHandle>(); const tabCtx = useContextMenu<Tab>();
// A group whose runs the list has not caught up with yet is not drawn.
const tabs = useMemo<Tab[]>(
() =>
groups.groups.flatMap((l, index) => {
const slots = leaves(l.root);
const own = slots
.map((s) => runs.find((r) => (r.slot ?? 0) === s))
.filter((r): r is RunHandle => !!r);
if (!own.length) return [];
const focus = own.find((r) => (r.slot ?? 0) === l.focus) ?? (own[0] as RunHandle);
return [{ index, key: slots.join("-"), slots, runs: own, focus }];
}),
[groups, runs]
);
// A tab or a pane header dragged along the bar: `dropAt` is the insertion
// point among the drawn tabs, from the pointer against their middles.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
const isBarDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types;
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
};
const dropIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return insertionIndex(
els.map((el) => {
const b = el.getBoundingClientRect();
return b.left + b.width / 2;
}),
x
);
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientX);
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
setDropAt(null);
if (!isBarDrag(e)) return;
e.preventDefault();
// The drawn position, as an index into the groups.
const drawn = dropIndexAt(e.clientX);
const at = tabs[drawn]?.index ?? groups.groups.length;
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
if (tab !== "") {
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
return;
}
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
if (raw === "") return;
const slot = Number(raw);
onGroupsChange((g) => {
const from = groupOf(g, slot);
if (from < 0) return g;
// A pane of a combo leaves it; a one-pane tab just moves.
return leaves(g.groups[from]?.root ?? null).length > 1
? detachSlot(g, slot, at)
: reorderGroups(g, from, at);
});
},
};
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
@@ -170,11 +267,17 @@ export function ConsoleTabs({
}; };
}, [menuOpen]); }, [menuOpen]);
const slot = freeSlot(lane.id, tabs); const slot = freeSlot(lane.id, runs);
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined); const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
const toolLabel = (r: RunHandle) => const toolLabel = (r: RunHandle) =>
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool); r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal")); const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
// A combo's dot is its most urgent AI pane's; a shell has none.
const tabState = (tab: Tab): RunState | null => {
const states = tab.runs.filter((r) => r.tool !== "shell").map((r) => runState(sessionOf(r)));
return URGENCY.find((x) => states.includes(x)) ?? null;
};
const toolIds = newTabTools(lane, aiTools); const toolIds = newTabTools(lane, aiTools);
const open = async (body: Record<string, unknown>, split = false) => { const open = async (body: Record<string, unknown>, split = false) => {
@@ -187,24 +290,30 @@ export function ConsoleTabs({
} }
}; };
// An AI tab may be mid-turn: stopping it asks first. A shell holds no agent. // An AI session may be mid-turn: stopping a tab holding one asks first. A
const close = async (r: RunHandle) => { // shell holds no agent.
const close = async (tab: Tab) => {
if ( if (
r.tool !== "shell" && tab.runs.some((r) => r.tool !== "shell") &&
!(await confirmDialog({ !(await confirmDialog({
title: t("tabs.stopTitle", { name: label(r) }), title:
tab.runs.length > 1
? t("tabs.stopGroupTitle", { count: tab.runs.length })
: t("tabs.stopTitle", { name: label(tab.focus) }),
message: t("tabs.stopBody"), message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"), confirmLabel: t("tabs.stopConfirm"),
destructive: true, destructive: true,
})) }))
) )
return; return;
for (const r of tab.runs) {
try { try {
await api.run.kill(r.id); await api.run.kill(r.id);
onClosed(r.id); onClosed(r.id);
} catch (err) { } catch (err) {
toast.error(err); toast.error(err);
} }
}
}; };
const startRename = (r: RunHandle) => { const startRename = (r: RunHandle) => {
@@ -243,22 +352,28 @@ export function ConsoleTabs({
: null; : null;
if (to === null || !tabs[to]) return; if (to === null || !tabs[to]) return;
e.preventDefault(); e.preventDefault();
tabRefs.current.get(tabs[to].id)?.focus(); tabRefs.current.get(tabs[to].key)?.focus();
}; };
const tabStop = tabs.some((r) => r.id === activeRunId) ? activeRunId : tabs[0]?.id; const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
// A tab-menu item: close the menu, then act on the tab it was opened on. // A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (r: RunHandle) => unknown) => () => { const pickTab = (fn: (tab: Tab) => unknown) => () => {
const r = tabCtx.menu?.item; const tab = tabCtx.menu?.item;
tabCtx.close(); tabCtx.close();
if (r) fn(r); if (tab) fn(tab);
}; };
const ctxTab = tabCtx.menu?.item;
const ctxRun = ctxTab?.runs.length === 1 ? ctxTab.focus : null;
const item = const item =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"; "flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return ( return (
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1"> // The whole bar takes a tab's drop: past the last tab is the end.
<div
className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1"
{...dragHandlers}
>
{onHome && ( {onHome && (
<button <button
type="button" type="button"
@@ -271,15 +386,21 @@ export function ConsoleTabs({
<House className="h-4 w-4" /> <House className="h-4 w-4" />
</button> </button>
)} )}
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch"> <div
{tabs.map((r, i) => { ref={listRef}
const active = r.id === activeRunId; role="tablist"
const shown = !active && shownRunIds.includes(r.id); aria-label={t("tabs.label")}
if (renaming === r.id) { className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
>
{tabs.map((tab, i) => {
const active = tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const state = tabState(tab);
if (single && renaming === single.id) {
return ( return (
<div key={r.id} className="flex items-center px-2"> <div key={tab.key} className="flex items-center px-2">
<input <input
data-testid={`console-tab-rename-${r.slot ?? 0}`} data-testid={`console-tab-rename-${single.slot ?? 0}`}
aria-label={t("tree.renameSession")} aria-label={t("tree.renameSession")}
autoFocus autoFocus
value={draft} value={draft}
@@ -291,7 +412,7 @@ export function ConsoleTabs({
if (e.key === "Escape") setRenaming(null); if (e.key === "Escape") setRenaming(null);
if (!isSubmitEnter(e)) return; if (!isSubmitEnter(e)) return;
e.preventDefault(); e.preventDefault();
void saveRename(r); void saveRename(single);
}} }}
className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary" className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary"
/> />
@@ -300,58 +421,79 @@ export function ConsoleTabs({
} }
return ( return (
<div <div
key={r.id} key={tab.key}
data-tab-index={i}
data-slots={tab.key}
draggable draggable
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)} onDragStart={(e) => {
onContextMenu={(e) => tabCtx.open(e, r)} if (single) startPaneDrag(e, single.slot ?? 0);
data-shown={shown ? "true" : undefined} else {
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${ e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
className={`group relative flex min-w-0 shrink-0 items-center rounded-t-md ${
single ? "max-w-[15rem]" : "max-w-[22rem]"
} ${
active active
? "bg-surface-0 text-fg-primary" ? "bg-surface-0 text-fg-primary"
: shown
? "text-fg-secondary hover:bg-surface-4"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary" : "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`} }`}
> >
{(active || shown) && ( {(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
<span <span
className={`absolute inset-x-2 -bottom-px h-0.5 rounded-full ${ aria-hidden
active ? "bg-accent" : "bg-fg-muted/50" data-testid="console-tab-drop-line"
className={`pointer-events-none absolute inset-y-1.5 z-10 w-0.5 rounded-full bg-accent-solid ${
dropAt === i ? "-left-px" : "-right-px"
}`} }`}
/> />
)} )}
{active && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
<button <button
type="button" type="button"
role="tab" role="tab"
aria-selected={active} aria-selected={active}
tabIndex={r.id === tabStop ? 0 : -1} tabIndex={tab.key === tabStop ? 0 : -1}
ref={(el) => { ref={(el) => {
if (el) tabRefs.current.set(r.id, el); if (el) tabRefs.current.set(tab.key, el);
else tabRefs.current.delete(r.id); else tabRefs.current.delete(tab.key);
}} }}
onKeyDown={(e) => onTabKey(e, i)} onKeyDown={(e) => onTabKey(e, i)}
data-testid={`console-tab-${r.slot ?? 0}`} data-testid={`console-tab-${tab.key}`}
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)} // Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
onDoubleClick={() => startRename(r)} onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
title={[ onDoubleClick={() => single && startRename(single)}
toolLabel(r), title={
r.model, single
r.promptPreview, ? [
toolLabel(single),
single.model,
single.promptPreview,
t("tabs.splitHint"), t("tabs.splitHint"),
r.sessionId ? t("tabs.renameHint") : null, single.sessionId ? t("tabs.renameHint") : null,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n")} .join("\n")
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm" className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
> >
<RunIcon run={r} label={toolLabel(r)} /> <span className="flex shrink-0 items-center gap-1">
<span className="truncate">{label(r)}</span> {tab.runs.map((r) => (
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />} <RunIcon key={r.id} run={r} label={toolLabel(r)} />
))}
</span>
<span className="truncate">{tabLabel(tab)}</span>
{state && <RunDot state={state} />}
</button> </button>
<button <button
type="button" type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`} data-testid={`console-tab-close-${tab.key}`}
onClick={() => void close(r)} onClick={() => void close(tab)}
aria-label={t("tabs.close")} aria-label={t("tabs.close")}
title={t("tabs.close")} title={t("tabs.close")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${ className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${
@@ -421,30 +563,36 @@ export function ConsoleTabs({
)} )}
</div> </div>
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>} {actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
{tabCtx.menu && ( {tabCtx.menu && ctxTab && (
<ContextMenu <ContextMenu
x={tabCtx.menu.x} x={tabCtx.menu.x}
y={tabCtx.menu.y} y={tabCtx.menu.y}
onClose={tabCtx.close} onClose={tabCtx.close}
testId={`console-tab-menu-${tabCtx.menu.item.slot ?? 0}`} testId={`console-tab-menu-${ctxTab.key}`}
> >
<MenuItem onSelect={pickTab((r) => onOpen(r.id))}>{t("sessionMenu.open")}</MenuItem> <MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id))}>
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}> {t("sessionMenu.open")}
</MenuItem>
{ctxRun && (
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id, true))}>
{t("sessionMenu.openSplit")} {t("sessionMenu.openSplit")}
</MenuItem> </MenuItem>
{tabCtx.menu.item.sessionId && tabCtx.menu.item.tool !== "shell" && ( )}
{ctxRun?.sessionId && ctxRun.tool !== "shell" && (
<> <>
<MenuItem onSelect={pickTab(startRename)}>{t("menu.rename")}</MenuItem> <MenuItem onSelect={pickTab((tab) => startRename(tab.focus))}>
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}> {t("menu.rename")}
</MenuItem>
<MenuItem onSelect={pickTab((tab) => navigate(`/sessions/${tab.focus.sessionId}`))}>
{t("sessionMenu.details")} {t("sessionMenu.details")}
</MenuItem> </MenuItem>
<MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}> <MenuItem onSelect={pickTab((tab) => void copyText(tab.focus.sessionId ?? ""))}>
{t("sessionMenu.copyId")} {t("sessionMenu.copyId")}
</MenuItem> </MenuItem>
</> </>
)} )}
<MenuSeparator /> <MenuSeparator />
<MenuItem danger onSelect={pickTab((r) => void close(r))}> <MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")} {t("tabs.close")}
</MenuItem> </MenuItem>
</ContextMenu> </ContextMenu>
@@ -5,7 +5,7 @@
* whose button goes back to the workspace home, where a session starts with a * whose button goes back to the workspace home, where a session starts with a
* prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts * prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts
* a bare CLI or a shell). No header of its own: the tab bar is the console's * a bare CLI or a shell). No header of its own: the tab bar is the console's
* header. The Workspace page keeps one per tab (slot 0–3) mounted, the * header. The Workspace page keeps one per live session (slot 0–9) mounted, the
* inactive ones hidden with CSS. * inactive ones hidden with CSS.
* *
* The pane is bound to the lane it shows: switching `laneId` swaps it to the * The pane is bound to the lane it shows: switching `laneId` swaps it to the
@@ -97,7 +97,7 @@ export function LaneConsolePane({
// arrives — the request must land after that. Only this lane's own tabs. // arrives — the request must land after that. Only this lane's own tabs.
useEffect(() => { useEffect(() => {
if (!attachRequest || laneId === null) return; if (!attachRequest || laneId === null) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return; if (!new RegExp(`^agenthub-lane-${laneId}(-[1-9])?$`).test(attachRequest.runId)) return;
let live = true; let live = true;
autoAttachedForLane.current = laneId; autoAttachedForLane.current = laneId;
api.run api.run
+44 -31
View File
@@ -8,8 +8,10 @@
* remount it and drop what it drew. With more than one pane each gets a slim * remount it and drop what it drew. With more than one pane each gets a slim
* header: drag it to move the pane (drop on another pane's edge to split * header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it * there, on its middle to swap), pick another live session for it, split it
* right or down, or HIDE the pane (its session keeps running as a tab — so * right or down, or take the pane OUT of this tab (its session keeps running
* its icon is not the tab's stop X). A header names the pane's session and * in a tab of its own — so its icon is not the tab's stop X). The grid shows
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does. * shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A * Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* right-click on a header offers the swap, a split with a new terminal and * right-click on a header offers the swap, a split with a new terminal and
@@ -33,8 +35,9 @@ import {
import type { RunHandle } from "../../lib/api"; import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { import {
EMPTY_LAYOUT, EMPTY_GROUPS,
type Edge, type Edge,
type PaneGroups,
type PaneLayout, type PaneLayout,
type Rect, type Rect,
type SplitDir, type SplitDir,
@@ -42,9 +45,9 @@ import {
closeInLayout, closeInLayout,
layoutRects, layoutRects,
leaves, leaves,
loadLayout, loadGroups,
moveInLayout, moveInLayout,
saveLayout, saveGroups,
setRatio, setRatio,
swapInLayout, swapInLayout,
} from "../../lib/paneLayout"; } from "../../lib/paneLayout";
@@ -72,13 +75,15 @@ export function RunIcon({ run, label }: { run: RunHandle; label: string }) {
} }
export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void; export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
/** A workspace's pane layout, loaded and saved per workspace. A layout update /** A workspace's tabs (groups of split panes), loaded and saved per
* that lands after a workspace switch never writes into the new one. */ * workspace. An update that lands after a workspace switch never writes into
export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] { * the new one. */
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
const load = (id: number | null) => ({ const load = (id: number | null) => ({
laneId: id, laneId: id,
layout: id === null ? EMPTY_LAYOUT : loadLayout(id), groups: id === null ? EMPTY_GROUPS : loadGroups(id),
}); });
const [state, setState] = useState(() => load(laneId)); const [state, setState] = useState(() => load(laneId));
let current = state; let current = state;
@@ -87,34 +92,41 @@ export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate]
setState(current); setState(current);
} }
useEffect(() => { useEffect(() => {
if (state.laneId !== null) saveLayout(state.laneId, state.layout); if (state.laneId !== null) saveGroups(state.laneId, state.groups);
}, [state]); }, [state]);
const update = useCallback<LayoutUpdate>( const update = useCallback<GroupsUpdate>(
(fn) => (fn) =>
setState((s) => { setState((s) => {
if (s.laneId !== laneId) return s; if (s.laneId !== laneId) return s;
const layout = fn(s.layout); const groups = fn(s.groups);
return layout === s.layout ? s : { ...s, layout }; return groups === s.groups ? s : { ...s, groups };
}), }),
[laneId] [laneId]
); );
return [current.layout, update]; return [current.groups, update];
} }
type Zone = Edge | "center"; export type Zone = Edge | "center";
const pct = (r: Rect) => ({ const pct = (r: Rect) => ({
left: `${r.x * 100}%`, left: `${r.x * 100}%`,
top: `${r.y * 100}%`, top: `${r.y * 100}%`,
width: `${r.w * 100}%`, width: `${r.w * 100}%`,
height: `${r.h * 100}%`, height: `${r.h * 100}%`,
}); });
/** The half (or the whole) of a pane a drop zone would fill. */ /** What dropping `src` on a pane's zone does: the center swaps, an edge
function zoneRect(r: Rect, zone: Zone): Rect { * splits. The drop applies it and the preview draws it, so they agree. */
if (zone === "left") return { ...r, w: r.w / 2 }; export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 }; return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
if (zone === "top") return { ...r, h: r.h / 2 }; }
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
return r; /** Where `src` would land, or null for a drop that changes nothing. A pane
* already shown leaves its place first, so its target grows before the
* split — the target's own half is not where it lands. */
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
if (src === slot) return null;
const next = applyDrop(l, src, slot, zone);
if (next === l) return null;
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
} }
/** A small anchored menu: a trigger button and a list that closes on a pick, /** A small anchored menu: a trigger button and a list that closes on a pick,
@@ -318,7 +330,7 @@ export function PaneGrid({
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null); const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
const { panes, dividers } = layoutRects(layout.root); const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root); const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p])); const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -386,6 +398,8 @@ export function PaneGrid({
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" }; return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
}; };
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
const isPaneDrag = (e: React.DragEvent) => const isPaneDrag = (e: React.DragEvent) =>
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE); dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
@@ -399,7 +413,10 @@ export function PaneGrid({
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
const z = zoneAt(e); const z = zoneAt(e);
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z)); const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
);
}} }}
onDragLeave={(e) => { onDragLeave={(e) => {
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null); if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
@@ -412,11 +429,7 @@ export function PaneGrid({
dragSlot = null; dragSlot = null;
setDrop(null); setDrop(null);
if (!z || !Number.isInteger(src) || src === z.slot) return; if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) => onChange((l) => applyDrop(l, src, z.slot, z.zone));
z.zone === "center"
? swapInLayout(l, src, z.slot)
: moveInLayout(l, src, z.slot, z.zone as Edge)
);
}} }}
> >
{mounted.map((slot) => { {mounted.map((slot) => {
@@ -556,11 +569,11 @@ export function PaneGrid({
} }
/> />
))} ))}
{drop && rectOf.has(drop.slot) && ( {preview && (
<div <div
data-testid="pane-drop-preview" data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100" className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))} style={pct(preview)}
/> />
)} )}
</div> </div>
+104
View File
@@ -0,0 +1,104 @@
/**
* @file ProxyPicker.tsx
* @description The AI Proxy a NEW session starts on — a session's choice, not
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
* Native or one of the saved profiles (`useAiProxies`, which the caller also
* reads to list the proxy's models). A tool that cannot use a proxy (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.proxy`; the pre-rename
* `agenthub.session.router` is still read when that is unset).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiProxy } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.proxy";
const LEGACY_KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionProxy(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiProxies(): AiProxy[] | null {
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
useEffect(() => {
let alive = true;
api.aiProxies
.list()
.then((r) => alive && setProxies(r.items))
.catch(() => alive && setProxies([]));
return () => {
alive = false;
};
}, []);
return proxies;
}
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
}
export function ProxyPicker({
tool,
proxies,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiProxies`. */
proxies: AiProxy[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
const native = t("proxy.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a proxy the user cannot see.
useEffect(() => {
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
}, [proxies, value, onChange]);
const options =
capable && proxies?.length
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="proxy-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("proxy.label")}
/>
</div>
);
}
-102
View File
@@ -1,102 +0,0 @@
/**
* @file RouterPicker.tsx
* @description The AI Router a NEW session starts on — a session's choice, not
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
* Native or one of the saved profiles (`useAiRouters`, which the caller also
* reads to list the router's models). A tool that cannot use a router (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.router`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiRouter } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionRouter(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiRouters(): AiRouter[] | null {
const [routers, setRouters] = useState<AiRouter[] | null>(null);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => alive && setRouters(r.items))
.catch(() => alive && setRouters([]));
return () => {
alive = false;
};
}, []);
return routers;
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
routers,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiRouters`. */
routers: AiRouter[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const native = t("router.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a router the user cannot see.
useEffect(() => {
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
}, [routers, value, onChange]);
const options =
capable && routers?.length
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="router-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("router.label")}
/>
</div>
);
}
+4 -4
View File
@@ -10,7 +10,7 @@
* (`POST /api/run/drop-image`); * (`POST /api/run/drop-image`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses; * - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`) * - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Router's models), plus Custom…; * or a fixed one (an AI Proxy's models), plus Custom…;
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's * - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under * "Sonnet 5.5 · Medium": the main models with their hints, the rest under
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset * More models, Custom…, and the Effort slider (Faster → Smarter, a reset
@@ -671,7 +671,7 @@ const MODEL_CUSTOM = "__custom__";
/** /**
* The models `tool` can start on, the inherit entry (`""`) first: the tool's * The models `tool` can start on, the inherit entry (`""`) first: the tool's
* own list (Codex's models cache, Claude's aliases named after the newest * own list (Codex's models cache, Claude's aliases named after the newest
* version, a catalog tool's vendor rows), or `fixed` (a router's models); * version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
* Claude's static aliases only when the server cannot answer. `loaded` once * Claude's static aliases only when the server cannot answer. `loaded` once
* the list is final. * the list is final.
*/ */
@@ -707,7 +707,7 @@ export function ModelPicker({
value: string; value: string;
onChange: (s: string) => void; onChange: (s: string) => void;
tool: LaneTool; tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */ /** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[]; choices?: ModelChoice[];
variant?: "field" | "ghost"; variant?: "field" | "ghost";
}) { }) {
@@ -781,7 +781,7 @@ export function ModelMenu({
value: string; value: string;
onChange: (s: string) => void; onChange: (s: string) => void;
tool: LaneTool; tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */ /** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[]; choices?: ModelChoice[];
/** Omitted for a tool that takes no effort. */ /** Omitted for a tool that takes no effort. */
effort?: EffortLevel; effort?: EffortLevel;
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(), action: vi.fn(),
list: vi.fn(), list: vi.fn(),
}, },
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) }, aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiTools: { aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }), list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }), models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
@@ -0,0 +1,36 @@
/**
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
describe("dropPreview", () => {
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
});
it("a pane from another tab splits the target's current box", () => {
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
});
it("the center swaps into the target's box", () => {
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
});
it("nothing on its own pane or on a refused move", () => {
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
const full: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
focus: 0,
};
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
@@ -1,16 +1,16 @@
/** /**
* @file RouterPicker.test.tsx * @file ProxyPicker.test.tsx
* @description The per-session AI Router picker: Native plus the saved * @description The per-session AI Proxy picker: Native plus the saved
* profiles, Native alone and disabled on a tool that cannot use a router, * profiles, Native alone and disabled on a tool that cannot use a proxy,
* remembering the pick, and falling back to Native when the remembered * remembering the pick, and falling back to Native when the remembered
* profile is gone; plus `routerArg`, which never sends native. * profile is gone; plus `proxyArg`, which never sends native.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
import type { AiRouter } from "../../../lib/api"; import type { AiProxy } from "../../../lib/api";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker"; import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
list: vi.fn().mockResolvedValue({ list: vi.fn().mockResolvedValue({
default: "claude", default: "claude",
tools: [ tools: [
{ id: "claude", label: "Claude Code", routerCapable: true }, { id: "claude", label: "Claude Code", proxyCapable: true },
{ id: "gemini", label: "Gemini", routerCapable: false }, { id: "gemini", label: "Gemini", proxyCapable: false },
].map((x) => ({ ...x, installed: true, enabled: true })), ].map((x) => ({ ...x, installed: true, enabled: true })),
}), }),
}, },
aiRouters: { list: vi.fn() }, aiProxies: { list: vi.fn() },
}, },
}; };
}); });
const native = { name: "native", label: "Native", builtin: true } as AiRouter; const native = { name: "native", label: "Native", builtin: true } as AiProxy;
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter; const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
vi.mocked(api.aiRouters.list) vi.mocked(api.aiProxies.list)
.mockReset() .mockReset()
.mockResolvedValue({ items: [native, nine] } as never); .mockResolvedValue({ items: [native, nine] } as never);
}); });
describe("RouterPicker", () => { describe("ProxyPicker", () => {
it("lists the saved profiles for a tool that can use one", async () => { it("lists the saved profiles for a tool that can use one", async () => {
render( render(
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} /> <ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
); );
expect(await screen.findByText("9Router")).toBeInTheDocument(); expect(await screen.findByText("9Router")).toBeInTheDocument();
}); });
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => { it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
render( render(
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} /> <ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
); );
const picker = screen.getByTestId("router-picker"); const picker = screen.getByTestId("proxy-picker");
expect(picker.querySelector("button")).toBeDisabled(); expect(picker.querySelector("button")).toBeDisabled();
expect(picker).not.toHaveTextContent("9Router"); expect(picker).not.toHaveTextContent("9Router");
}); });
it("falls back to Native when the remembered profile is gone", async () => { it("falls back to Native when the remembered profile is gone", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />); render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native")); await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
}); });
it("useAiRouters reads the saved profiles once", async () => { it("useAiProxies reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiRouters()); const { result } = renderHook(() => useAiProxies());
await waitFor(() => expect(result.current).toEqual([native, nine])); await waitFor(() => expect(result.current).toEqual([native, nine]));
expect(api.aiRouters.list).toHaveBeenCalledTimes(1); expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
}); });
}); });
describe("useSessionRouter / routerArg", () => { describe("useSessionProxy / proxyArg", () => {
it("remembers the last pick for the next session", () => { it("remembers the last pick for the next session", () => {
const first = renderHook(() => useSessionRouter()); const first = renderHook(() => useSessionProxy());
expect(first.result.current[0]).toBe("native"); expect(first.result.current[0]).toBe("native");
act(() => first.result.current[1]("9router")); act(() => first.result.current[1]("9router"));
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router"); expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
}); });
it("sends a router only for a capable tool and never native", () => { it("still reads the pick remembered before the rename", () => {
expect(routerArg(true, "9router")).toEqual({ router: "9router" }); localStorage.setItem("agenthub.session.router", "9router");
expect(routerArg(true, "native")).toEqual({}); expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
expect(routerArg(false, "9router")).toEqual({}); });
it("sends a proxy only for a capable tool and never native", () => {
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
expect(proxyArg(true, "native")).toEqual({});
expect(proxyArg(false, "9router")).toEqual({});
}); });
}); });
@@ -4,7 +4,7 @@
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is * `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a * open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own * pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Router's models) when given one, * list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the * and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
* same list as one chip: aliases up front, pinned versions under More models, * same list as one chip: aliases up front, pinned versions under More models,
* plus the effort slider. * plus the effort slider.
@@ -211,7 +211,7 @@ describe("ModelPicker", () => {
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy(); expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
}); });
it("offers only a fixed list when given one (an AI Router's models)", async () => { it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
render( render(
<ModelPicker <ModelPicker
value="" value=""
@@ -414,7 +414,7 @@ export function AiToolsSettings() {
> >
<pre <pre
data-testid="ai-tool-confirm-command" data-testid="ai-tool-confirm-command"
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary" className="mt-3 whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
> >
{confirmLines.join("\n")} {confirmLines.join("\n")}
</pre> </pre>
@@ -1,8 +1,10 @@
/** /**
* @file AppearanceSettings.tsx * @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and * @description Settings → Appearance (`/settings/appearance`): the interface language, the
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in * Dark / Light / System mode switch and one named-theme picker per mode, and the
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its * corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is 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 * 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. * is painted by the same tokens as the app — no hex is duplicated here.
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes. * **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
@@ -11,7 +13,8 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react"; import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSegmented } from "./SettingsRow"; import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme"; import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
@@ -61,7 +64,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
} }
export function AppearanceSettings() { export function AppearanceSettings() {
const { t } = useTranslation("settings"); const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme(); const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const reset = async () => { const reset = async () => {
@@ -78,7 +81,30 @@ export function AppearanceSettings() {
}; };
return ( return (
<div className="card p-5 space-y-5"> <div className="space-y-6">
<SettingsSection
id="language"
title={t("language.title")}
description={t("language.description")}
>
<SettingsRow
label={t("language.label")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: t("language.names.en") },
{ value: "vi" as const, label: t("language.names.vi") },
]}
/>
}
/>
</SettingsSection>
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow <SettingsRow
label={t("appearance.mode")} label={t("appearance.mode")}
control={ control={
@@ -139,6 +165,15 @@ export function AppearanceSettings() {
{t("appearance.reset")} {t("appearance.reset")}
</button> </button>
</div> </div>
</section>
<section id="pet" className="space-y-3 scroll-mt-24">
<div>
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.pet")}</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("appearance.petDescription")}</p>
</div>
<PetSettings />
</section>
</div> </div>
); );
} }
@@ -0,0 +1,58 @@
/**
* @file AutostartSettings — Settings → About's "Start automatically" switch:
* whether the dashboard starts at boot/login through the per-user service
* (systemd `--user` unit, or a LaunchAgent on macOS), over
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
* stops the running server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AutostartStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function AutostartSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<AutostartStatus | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.autostart
.get()
.then(setStatus)
.catch(() => {});
}, []);
const change = async (enabled: boolean) => {
setSaving(true);
try {
setStatus(await api.settings.autostart.set(enabled));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const note = !status
? undefined
: !status.available
? t("autostart.unavailable")
: status.foreign
? t("autostart.foreign")
: t(`autostart.desc_${status.kind}`);
return (
<SettingsSection id="autostart" title={t("autostart.title")}>
<SettingsSwitchRow
label={t("autostart.label")}
description={note}
checked={!!status?.enabled}
disabled={!status?.available || status.foreign || saving}
onChange={(v) => void change(v)}
/>
</SettingsSection>
);
}
@@ -17,6 +17,7 @@ import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow"; import { SettingsSection } from "./SettingsRow";
import { PathPickButton } from "../PathPickButton";
export interface HomeDirValue { export interface HomeDirValue {
value: string; value: string;
@@ -122,6 +123,13 @@ export function HomeDirSetting({
className="input flex-1 font-mono" className="input flex-1 font-mono"
placeholder={placeholder} placeholder={placeholder}
/> />
<PathPickButton
start={input}
onPick={(p) => {
setInput(p);
setError(null);
}}
/>
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
@@ -1,5 +1,5 @@
/** /**
* @file PetSettings — Settings → Pet: the mascot's own page. Show it or not, * @file PetSettings — the pet block of Settings → Appearance (`#pet`). Show it or not,
* its speech bubbles, its size (with a live preview), and which pet it wears * its speech bubbles, its size (with a live preview), and which pet it wears
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this * from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
* install on the first pick, never bundled), and your own (deletable) — plus * install on the first pick, never bundled), and your own (deletable) — plus
@@ -4,7 +4,7 @@
* computed from — as shown on a tool's Settings tab (Overview for Claude Code * computed from — as shown on a tool's Settings tab (Overview for Claude Code
* and Codex, the Antigravity tab itself): Claude * and Codex, the Antigravity tab itself): Claude
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`, * Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a * `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a
* **Show all models** switch lists every row, and a row added outside the * **Show all models** switch lists every row, and a row added outside the
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's * filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
* rates, so only the Claude tab offers it; the server re-fetches any row the * rates, so only the Claude tab offers it; the server re-fetches any row the
@@ -710,8 +710,13 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
onContextMenu={(e) => ctx.open(e, rule)} onContextMenu={(e) => ctx.open(e, rule)}
className="hover:bg-surface-4 transition-colors group" className="hover:bg-surface-4 transition-colors group"
> >
<td className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary"> {/* The trailing wildcard is noise in a list of prefixes; the
{rule.model_pattern} full pattern stays in the tooltip, the editor and Copy. */}
<td
title={rule.model_pattern}
className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary"
>
{rule.model_pattern.replace(/%$/, "")}
</td> </td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-fg-secondary"> <td className="whitespace-nowrap px-3 py-3 text-sm text-fg-secondary">
{rule.display_name} {rule.display_name}
@@ -0,0 +1,111 @@
/**
* @file PrivacySettings.tsx
* @description Settings → Data → Privacy: the two opt-in ingest controls of
* `server/lib/privacy.js` — redact secrets (API keys, tokens, bearer headers,
* private keys, `password=…`) from the payloads and summaries AgentHub stores,
* and a list of folders whose hooks are dropped whole (one absolute path per
* line; a folder and everything under it). Both persist through
* `PUT /api/settings/privacy`; one the environment set is shown read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ShieldCheck } from "lucide-react";
import { api } from "../../lib/api";
import type { PrivacySettings as PrivacyState } from "../../lib/api";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
const lines = (text: string) =>
text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
export function PrivacySettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<PrivacyState | null>(null);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const apply = (s: PrivacyState) => {
setState(s);
setDraft(s.ignore_cwd.join("\n"));
};
useEffect(() => {
api.settings.privacy
.get()
.then(apply)
.catch(() => undefined);
}, []);
async function save(patch: { redact_secrets?: boolean; ignore_cwd?: string[] }) {
setSaving(true);
setError(null);
try {
apply(await api.settings.privacy.save(patch));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
const listChanged = !!state && lines(draft).join("\n") !== state.ignore_cwd.join("\n");
return (
<div id="privacy" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-fg-muted" />
{t("privacy.title")}
</span>
}
description={t("privacy.description")}
>
<SettingsSwitchRow
label={t("privacy.redact")}
description={t("privacy.redactHint")}
checked={!!state?.redact_secrets}
disabled={!state || saving || state.env_owned.redact_secrets}
onChange={(v) => void save({ redact_secrets: v })}
/>
<div className="py-3 space-y-2">
<label htmlFor="privacy-ignore" className="block text-sm text-fg-secondary">
{t("privacy.ignore")}
</label>
<p className="text-xs text-fg-muted">{t("privacy.ignoreHint")}</p>
<textarea
id="privacy-ignore"
className="input w-full font-mono text-xs min-h-[72px]"
placeholder={"/home/you/clients/secret-project\n~/private"}
value={draft}
disabled={!state || state.env_owned.ignore_cwd}
onChange={(e) => {
setDraft(e.target.value);
setError(null);
}}
/>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !listChanged}
onClick={() => void save({ ignore_cwd: lines(draft) })}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
{error && <p className="text-xs text-status-danger">{error}</p>}
{(state?.env_owned.redact_secrets || state?.env_owned.ignore_cwd) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,170 @@
/**
* @file PushIngestSettings.tsx
* @description Settings → Data → Push ingest: the bearer token that opens
* `POST /api/hooks/ingest-batch`, for a machine this dashboard cannot reach
* over SSH, so the machine pushes its hooks instead. Off until a token is
* created; the token is shown once, right after it is made (only its hash is
* stored), and can be rotated or revoked. Also shows a forwarder script for
* the other machine — its token in a header file, never on a command line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Copy, KeyRound, RefreshCw, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import type { PushTokenStatus } from "../../lib/api";
import { ConfirmModal } from "../ConfirmModal";
import { SettingsSection } from "./SettingsRow";
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
function forwarder(origin: string) {
return `#!/bin/sh
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
# Authorization: Bearer <token>
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
'{source: $s, events: [{hook_type: $t, data: .}]}' |
curl -s -m 5 -o /dev/null -X POST "${origin}/api/hooks/ingest-batch" \\
-H "@$HOME/.claude/agenthub-push.header" \\
-H "Content-Type: application/json" --data-binary @-
exit 0`;
}
export function PushIngestSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<PushTokenStatus | null>(null);
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Rotating a live token or revoking one asks first.
const [confirm, setConfirm] = useState<"rotate" | "revoke" | null>(null);
useEffect(() => {
api.settings.pushToken
.get()
.then(setStatus)
.catch(() => undefined);
}, []);
async function run(fn: () => Promise<void>) {
setConfirm(null);
setBusy(true);
setError(null);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
const rotate = () =>
run(async () => {
const r = await api.settings.pushToken.rotate();
setToken(r.token);
setStatus({ enabled: r.enabled, env_owned: r.env_owned });
});
const revoke = () =>
run(async () => {
setStatus(await api.settings.pushToken.revoke());
setToken(null);
});
const script = forwarder(window.location.origin);
return (
<div id="push-ingest" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-fg-muted" />
{t("pushIngest.title")}
</span>
}
description={t("pushIngest.description")}
>
<div className="space-y-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={`text-xs ${status?.enabled ? "text-status-success" : "text-fg-muted"}`}
>
{status?.enabled ? t("pushIngest.on") : t("pushIngest.off")}
</span>
<button
type="button"
className="btn-primary text-xs disabled:opacity-40"
disabled={busy || !status || status.env_owned}
onClick={() => (status?.enabled ? setConfirm("rotate") : void rotate())}
>
<RefreshCw className="w-3.5 h-3.5" />
{status?.enabled ? t("pushIngest.rotate") : t("pushIngest.create")}
</button>
{status?.enabled && (
<button
type="button"
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
disabled={busy || status.env_owned}
onClick={() => setConfirm("revoke")}
>
<Trash2 className="w-3.5 h-3.5" />
{t("pushIngest.revoke")}
</button>
)}
</div>
{token && (
<div className="rounded-lg border border-status-warning/25 bg-status-warning/10 p-3 space-y-2">
<p className="text-xs text-status-warning">{t("pushIngest.shownOnce")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 break-all text-xs text-fg-primary">{token}</code>
<button
type="button"
className="btn-ghost text-xs"
title={t("pushIngest.copy")}
onClick={() => void copyText(token)}
>
<Copy className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{status?.env_owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("pushIngest.envOwned")}
</p>
)}
<p className="text-xs text-fg-muted">{t("pushIngest.reachHint")}</p>
<div className="relative">
<pre className="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary">
{script}
</pre>
<button
type="button"
className="btn-ghost text-xs absolute top-1.5 right-1.5"
title={t("pushIngest.copy")}
onClick={() => void copyText(script)}
>
<Copy className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-xs text-fg-muted">{t("pushIngest.limits")}</p>
{error && <p className="text-xs text-status-danger">{error}</p>}
</div>
<ConfirmModal
open={confirm !== null}
title={t(confirm === "revoke" ? "pushIngest.revokeConfirm" : "pushIngest.rotateConfirm")}
confirmLabel={t(confirm === "revoke" ? "pushIngest.revoke" : "pushIngest.rotate")}
cancelLabel={t("common:cancel")}
destructive={confirm === "revoke"}
busy={busy}
onConfirm={() => void (confirm === "revoke" ? revoke() : rotate())}
onCancel={() => setConfirm(null)}
/>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,394 @@
/**
* @file ReportSettings.tsx
* @description Settings → Reports: scheduled report definitions (`/api/reports`)
* — create/edit (name, window, daily/weekly + weekday, local hour, HTML/JSON),
* enable switch, Run now, a confirmed Delete, and each report's run history
* with download links. Every row action is also on a right-click menu. The
* browser's timezone offset is saved with the definition, so its hour is the
* user's local hour.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, History, Pencil, Play, Plus, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import type { ReportDefinition, ReportInput, ReportRun } from "../../lib/types";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { Select } from "../Select";
import { Switch } from "../Switch";
import { SettingsSection } from "./SettingsRow";
const EMPTY: ReportInput = {
name: "",
window_days: 7,
frequency: "weekly",
hour: 8,
weekday: 1,
tz_offset: 0,
formats: ["html"],
enabled: true,
};
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
export function ReportSettings() {
const { t, i18n } = useTranslation("settings");
const [items, setItems] = useState<ReportDefinition[] | null>(null);
const [error, setError] = useState<string | null>(null);
/** The form: a new report (id null) or the one being edited. */
const [form, setForm] = useState<{ id: number | null; value: ReportInput } | null>(null);
const [deleting, setDeleting] = useState<ReportDefinition | null>(null);
const [busy, setBusy] = useState<number | "form" | null>(null);
const [history, setHistory] = useState<{ id: number; runs: ReportRun[] } | null>(null);
const ctx = useContextMenu<ReportDefinition>();
const weekday = (d: number) =>
new Intl.DateTimeFormat(i18n.resolvedLanguage ?? i18n.language, { weekday: "long" }).format(
new Date(Date.UTC(2026, 0, 4 + d))
);
const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err));
const load = useCallback(() => {
api.reports
.list()
.then((r) => setItems(r.reports))
.catch(fail);
}, []);
useEffect(load, [load]);
const showHistory = async (id: number) => {
try {
const r = await api.reports.runs(id);
setHistory({ id, runs: r.runs });
} catch (err) {
fail(err);
}
};
const save = async () => {
if (!form) return;
setBusy("form");
const value = { ...form.value, tz_offset: new Date().getTimezoneOffset() };
try {
if (form.id === null) await api.reports.create(value);
else await api.reports.update(form.id, value);
setForm(null);
setError(null);
load();
} catch (err) {
fail(err);
} finally {
setBusy(null);
}
};
const toggle = async (r: ReportDefinition, enabled: boolean) => {
try {
await api.reports.update(r.id, { enabled });
load();
} catch (err) {
fail(err);
}
};
const runNow = async (r: ReportDefinition) => {
setBusy(r.id);
try {
const { run } = await api.reports.run(r.id);
if (run.status === "error") setError(run.error);
load();
if (history?.id === r.id) await showHistory(r.id);
} catch (err) {
fail(err);
} finally {
setBusy(null);
}
};
const remove = async () => {
if (!deleting) return;
try {
await api.reports.remove(deleting.id);
if (history?.id === deleting.id) setHistory(null);
load();
} catch (err) {
fail(err);
} finally {
setDeleting(null);
}
};
const edit = (r: ReportDefinition) =>
setForm({
id: r.id,
value: {
name: r.name,
window_days: r.window_days,
frequency: r.frequency,
hour: r.hour,
weekday: r.weekday,
tz_offset: r.tz_offset,
formats: r.formats,
enabled: r.enabled,
},
});
const pick = (fn: (r: ReportDefinition) => void) => () => {
const r = ctx.menu?.item;
ctx.close();
if (r) fn(r);
};
const schedule = (r: ReportInput) =>
[
r.frequency === "daily"
? t("reports.everyDay", { time: `${pad(r.hour)}:00` })
: t("reports.everyWeek", { day: weekday(r.weekday), time: `${pad(r.hour)}:00` }),
t("reports.window", { count: r.window_days }),
r.formats.map((f) => f.toUpperCase()).join(", "),
].join(" · ");
const set = (patch: Partial<ReportInput>) =>
setForm((f) => (f ? { ...f, value: { ...f.value, ...patch } } : f));
const toggleFormat = (f: "html" | "json", on: boolean) =>
set({
formats: on
? [...(form?.value.formats ?? []), f]
: (form?.value.formats ?? []).filter((x) => x !== f),
});
return (
<SettingsSection title={t("reports.title")} description={t("reports.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{form ? (
<div
className="my-2 space-y-3 rounded-md border border-border p-3"
data-testid="report-form"
>
<label className="block text-xs text-fg-muted">
{t("reports.name")}
<input
className="input mt-1 w-full text-sm"
value={form.value.name}
maxLength={200}
onChange={(e) => set({ name: e.target.value })}
/>
</label>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-fg-muted">
{t("reports.windowDays")}
<input
type="number"
min={1}
max={366}
className="input mt-1 block w-24 text-sm"
value={form.value.window_days}
onChange={(e) => set({ window_days: Math.trunc(Number(e.target.value)) })}
/>
</label>
<div className="w-36">
<Select
value={form.value.frequency}
onChange={(v) => set({ frequency: v })}
label={t("reports.frequency")}
options={[
{ value: "daily", label: t("reports.daily") },
{ value: "weekly", label: t("reports.weekly") },
]}
/>
</div>
{form.value.frequency === "weekly" && (
<div className="w-40">
<Select
value={String(form.value.weekday)}
onChange={(v) => set({ weekday: Number(v) })}
label={t("reports.weekday")}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({
value: String(d),
label: weekday(d),
}))}
/>
</div>
)}
<div className="w-28">
<Select
value={String(form.value.hour)}
onChange={(v) => set({ hour: Number(v) })}
label={t("reports.hour")}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<Checkbox
checked={form.value.formats.includes("html")}
onChange={(v) => toggleFormat("html", v)}
label="HTML"
/>
<Checkbox
checked={form.value.formats.includes("json")}
onChange={(v) => toggleFormat("json", v)}
label="JSON"
/>
</div>
<p className="text-[11px] text-fg-muted">{t("reports.windowHint")}</p>
<div className="flex gap-2">
<button
type="button"
className="btn-primary text-xs"
disabled={
busy === "form" || !form.value.name.trim() || form.value.formats.length === 0
}
onClick={() => void save()}
>
{t("reports.save")}
</button>
<button type="button" className="btn-ghost text-xs" onClick={() => setForm(null)}>
{t("reports.cancel")}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn-ghost my-2 text-xs"
onClick={() => setForm({ id: null, value: { ...EMPTY, name: t("reports.defaultName") } })}
>
<Plus className="h-3.5 w-3.5" />
{t("reports.add")}
</button>
)}
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("reports.empty")}</p>}
{items && items.length > 0 && (
<ul className="divide-y divide-border/60" data-testid="report-list">
{items.map((r) => (
<li key={r.id} onContextMenu={(e) => ctx.open(e, r)} className="py-2.5">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg-secondary">{r.name}</p>
<p className="text-[11px] text-fg-muted">{schedule(r)}</p>
{r.last_run && (
<p
className={`text-[11px] ${
r.last_run.status === "error" ? "text-status-danger" : "text-fg-muted"
}`}
>
{t("reports.lastRun", { at: formatDateTime(r.last_run.created_at) })}
{r.last_run.status === "error" && ` · ${r.last_run.error ?? ""}`}
</p>
)}
</div>
<Switch
checked={r.enabled}
onChange={(v) => void toggle(r, v)}
label={t("reports.enabled")}
title={t("reports.enabled")}
/>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy === r.id}
onClick={() => void runNow(r)}
title={t("reports.runNow")}
aria-label={t("reports.runNow")}
>
<Play className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => (history?.id === r.id ? setHistory(null) : void showHistory(r.id))}
title={t("reports.history")}
aria-label={t("reports.history")}
>
<History className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => edit(r)}
title={t("reports.edit")}
aria-label={t("reports.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs text-status-danger"
onClick={() => setDeleting(r)}
title={t("reports.delete")}
aria-label={t("reports.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{history?.id === r.id && (
<ul
className="mt-2 space-y-1 border-l border-border pl-3"
data-testid="report-runs"
>
{history.runs.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("reports.noRuns")}</li>
)}
{history.runs.map((run) => (
<li key={run.id} className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-fg-secondary">{formatDateTime(run.created_at)}</span>
<span className="text-fg-muted">
{t(`reports.trigger.${run.trigger}`)} · {run.window_from} → {run.window_to}
</span>
{run.status === "error" ? (
<span className="text-status-danger">{run.error}</span>
) : (
run.files.map((f) => (
<a
key={f}
href={api.reports.fileUrl(r.id, run.id, f)}
download
className="inline-flex items-center gap-0.5 text-accent hover:underline"
title={t("reports.download", { format: f.toUpperCase() })}
>
<Download className="h-3 w-3" />
{f.toUpperCase()}
</a>
))
)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="report-menu">
<MenuItem onSelect={pick((r) => void runNow(r))}>{t("reports.runNow")}</MenuItem>
<MenuItem onSelect={pick((r) => void showHistory(r.id))}>{t("reports.history")}</MenuItem>
<MenuItem onSelect={pick(edit)}>{t("reports.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(setDeleting)}>
{t("reports.delete")}
</MenuItem>
</ContextMenu>
)}
<ConfirmModal
open={deleting !== null}
title={t("reports.deleteTitle")}
message={t("reports.deleteBody")}
confirmLabel={t("reports.delete")}
cancelLabel={t("reports.cancel")}
onConfirm={() => void remove()}
onCancel={() => setDeleting(null)}
/>
</SettingsSection>
);
}
@@ -0,0 +1,142 @@
/**
* @file SnapshotSettings.tsx
* @description Settings → Snapshots: every frozen session (`GET
* /api/snapshots`), each with Open, Copy link and a confirmed Delete — the
* buttons and the same actions on a right-click menu. Snapshots survive Clear
* Data, so this is the one place they go away.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import type { SessionSnapshotInfo } from "../../lib/types";
import { ConfirmModal } from "../ConfirmModal";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CopyButton } from "../event-views/primitives";
import { snapshotUrl } from "../SnapshotButton";
import { SettingsSection } from "./SettingsRow";
export function SnapshotSettings() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<SessionSnapshotInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<SessionSnapshotInfo | null>(null);
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<SessionSnapshotInfo>();
const navigate = useNavigate();
const load = useCallback(() => {
api.snapshots
.list()
.then((r) => setItems(r.snapshots))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, []);
useEffect(load, [load]);
const remove = async () => {
if (!deleting) return;
setBusy(true);
try {
await api.snapshots.remove(deleting.id);
setItems((xs) => xs?.filter((x) => x.id !== deleting.id) ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setDeleting(null);
}
};
const pick = (fn: (s: SessionSnapshotInfo) => void) => () => {
const s = ctx.menu?.item;
ctx.close();
if (s) fn(s);
};
return (
<SettingsSection title={t("snapshots.title")} description={t("snapshots.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("snapshots.empty")}</p>}
{items && items.length > 0 && (
<ul className="divide-y divide-border/60" data-testid="snapshot-list">
{items.map((s) => (
<li
key={s.id}
onContextMenu={(e) => ctx.open(e, s)}
className="flex items-center gap-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg-secondary">
{s.session_name || s.session_id.slice(0, 8)}
</p>
<p className="flex items-center gap-2 text-[11px] text-fg-muted">
<span>{formatDateTime(s.created_at)}</span>
<span>· {t("snapshots.events", { n: s.event_count ?? 0 })}</span>
{s.redacted && (
<span
className="inline-flex items-center gap-0.5"
title={t("snapshots.redacted")}
>
<ShieldCheck className="h-3 w-3" />
{t("snapshots.redacted")}
</span>
)}
</p>
</div>
<Link
to={`/snapshot/${s.token}`}
className="btn-ghost text-xs"
title={t("snapshots.open")}
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
<CopyButton text={snapshotUrl(s.token)} />
<button
type="button"
onClick={() => setDeleting(s)}
className="btn-ghost text-xs text-status-danger"
title={t("snapshots.delete")}
aria-label={t("snapshots.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="snapshot-menu">
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
{t("snapshots.open")}
</MenuItem>
<MenuItem
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
>
{t("snapshots.copyLink")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(setDeleting)}>
{t("snapshots.delete")}
</MenuItem>
</ContextMenu>
)}
<ConfirmModal
open={deleting !== null}
title={t("snapshots.deleteTitle")}
message={t("snapshots.deleteBody")}
confirmLabel={t("snapshots.delete")}
cancelLabel={t("snapshots.cancel")}
busy={busy}
onConfirm={() => void remove()}
onCancel={() => setDeleting(null)}
/>
</SettingsSection>
);
}
@@ -49,7 +49,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
prompt: { mode: "argv" }, prompt: { mode: "argv" },
resume: false, resume: false,
bypass: false, bypass: false,
routerCapable: false, proxyCapable: false,
install: null, install: null,
update: null, update: null,
modelFlag: false, modelFlag: false,
@@ -1,14 +1,17 @@
/** /**
* @file AppearanceSettings.test.tsx * @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>`. * @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import i18n from "../../../i18n";
import { AppearanceSettings } from "../AppearanceSettings"; import { AppearanceSettings } from "../AppearanceSettings";
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
const root = document.documentElement; const root = document.documentElement;
describe("AppearanceSettings", () => { describe("AppearanceSettings", () => {
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
root.removeAttribute("data-mode"); root.removeAttribute("data-mode");
}); });
it("switches the interface language and holds the pet block", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
expect(screen.getByTestId("pet-settings").closest("section")).toHaveAttribute("id", "pet");
const group = screen.getByRole("radiogroup", { name: "Interface language" });
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
"aria-checked",
"true"
);
await user.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
expect(i18n.language).toBe("vi");
await act(async () => {
await i18n.changeLanguage("en");
});
});
it("renders every theme of both modes, defaults selected", () => { it("renders every theme of both modes, defaults selected", () => {
render(<AppearanceSettings />); render(<AppearanceSettings />);
const pressed = screen const pressed = screen
@@ -78,7 +78,7 @@ beforeEach(() => {
describe("ClaudeAgentHubSettings", () => { describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => { it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
const { container } = render(<ClaudeAgentHubSettings />); const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument(); expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) { for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull(); expect(container.querySelector(`section#${id}`)).not.toBeNull();
} }
@@ -96,7 +96,7 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude"); await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement; const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes(); const off = confirmYes();
await user.click(within(home).getByRole("button")); await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude")); await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off(); off();
@@ -115,7 +115,7 @@ describe("ClaudeAgentHubSettings", () => {
const home = container.querySelector("section#claude-home") as HTMLElement; const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length; const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes(); const off = confirmYes();
await user.click(within(home).getByRole("button")); await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off(); off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads); expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
@@ -67,11 +67,12 @@ describe("PricingSettings", () => {
it("shows the vendor's rows, and the rest behind Show all models", async () => { it("shows the vendor's rows, and the rest behind Show all models", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PricingSettings vendor="openai" />); render(<PricingSettings vendor="openai" />);
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument(); // The trailing wildcard is hidden in the list, kept in the tooltip.
expect(screen.queryByText("claude-opus-5-5%")).toBeNull(); expect(await screen.findByText("gpt-5.6-sol")).toHaveAttribute("title", "gpt-5.6-sol%");
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull(); expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ })); await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument(); expect(screen.getByText("claude-opus-5-5")).toBeInTheDocument();
}); });
it("marks a row the automatic sync keeps up to date", async () => { it("marks a row the automatic sync keeps up to date", async () => {
@@ -82,7 +83,7 @@ describe("PricingSettings", () => {
], ],
}); });
render(<PricingSettings vendor="openai" />); render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-6-sol%"); await screen.findByText("gpt-6-sol");
expect(screen.getAllByText("Auto")).toHaveLength(1); expect(screen.getAllByText("Auto")).toHaveLength(1);
}); });
@@ -95,7 +96,7 @@ describe("PricingSettings", () => {
it("turns Show all on when a row outside the filter is added", async () => { it("turns Show all on when a row outside the filter is added", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PricingSettings vendor="openai" />); render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%"); await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByRole("button", { name: /Add Model/i })); await user.click(screen.getByRole("button", { name: /Add Model/i }));
const [pattern, name] = screen.getAllByRole("textbox"); const [pattern, name] = screen.getAllByRole("textbox");
await user.type(pattern!, "deepseek-v4%"); await user.type(pattern!, "deepseek-v4%");
@@ -104,7 +105,7 @@ describe("PricingSettings", () => {
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")], pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
}); });
await user.click(screen.getByTitle(/save/i)); await user.click(screen.getByTitle(/save/i));
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument(); expect(await screen.findByText("deepseek-v4")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked(); expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
}); });
@@ -112,8 +113,8 @@ describe("PricingSettings", () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS }); vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
render(<PricingSettings vendor="claude" allowReset />); render(<PricingSettings vendor="claude" allowReset />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument(); expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull(); expect(screen.queryByText("gpt-5.6-sol")).toBeNull();
await user.click(screen.getByRole("button", { name: /Reset Defaults/i })); await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
expect(api.settings.resetPricing).not.toHaveBeenCalled(); expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /confirm/i })); await user.click(screen.getByRole("button", { name: /confirm/i }));
@@ -128,7 +129,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(true); const confirm = answerConfirms(true);
const user = userEvent.setup(); const user = userEvent.setup();
render(<PricingSettings vendor="openai" />); render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!; const row = (await screen.findByText("gpt-5.6-sol")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row }); await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" })); await user.click(screen.getByRole("menuitem", { name: "Delete" }));
@@ -147,7 +148,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(false); const confirm = answerConfirms(false);
const user = userEvent.setup(); const user = userEvent.setup();
render(<PricingSettings vendor="openai" />); render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%"); await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByTitle("Delete")); await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1)); await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i); expect(confirm.asked[0]!.message).toMatch(/sync/i);
@@ -167,14 +168,14 @@ describe("PricingSettings", () => {
<PricingSettings vendor="openai" id="b" /> <PricingSettings vendor="openai" id="b" />
</> </>
); );
await screen.findByText("gpt-5.6-sol%"); await screen.findByText("gpt-5.6-sol");
await screen.findByText("claude-opus-5-5%"); await screen.findByText("claude-opus-5-5");
vi.mocked(api.pricing.list).mockResolvedValue({ vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")], pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
}); });
// The second table (openai) deletes its row; the first must reload too. // The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!); await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument(); expect(await screen.findByText("claude-fable-5")).toBeInTheDocument();
confirm.off(); confirm.off();
}); });
}); });
@@ -0,0 +1,61 @@
/**
* @file PushIngestSettings.test.tsx
* @description Settings → Data → Push ingest and Privacy: a created token is
* shown once (a reload shows only that push is on), and the ignored-folder
* list is saved one path per line with blank lines dropped.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { pushToken, privacy } = vi.hoisted(() => ({
pushToken: { get: vi.fn(), rotate: vi.fn(), revoke: vi.fn() },
privacy: { get: vi.fn(), save: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({ api: { settings: { pushToken, privacy } } }));
import { PushIngestSettings } from "../PushIngestSettings";
import { PrivacySettings } from "../PrivacySettings";
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("PushIngestSettings", () => {
it("shows a new token once, and never on a fresh load", async () => {
const user = userEvent.setup();
pushToken.get.mockResolvedValue({ enabled: false, env_owned: false });
pushToken.rotate.mockResolvedValue({ token: "ahp_secret", enabled: true, env_owned: false });
const view = render(<PushIngestSettings />);
await user.click(await screen.findByRole("button", { name: /Create token/ }));
expect(await screen.findByText("ahp_secret")).toBeInTheDocument();
view.unmount();
pushToken.get.mockResolvedValue({ enabled: true, env_owned: false });
render(<PushIngestSettings />);
expect(await screen.findByRole("button", { name: /Rotate token/ })).toBeInTheDocument();
expect(screen.queryByText("ahp_secret")).toBeNull();
});
});
describe("PrivacySettings", () => {
it("saves the ignored folders one per line", async () => {
const user = userEvent.setup();
const state = {
redact_secrets: false,
ignore_cwd: [],
env_owned: { redact_secrets: false, ignore_cwd: false },
};
privacy.get.mockResolvedValue(state);
privacy.save.mockImplementation((p) => Promise.resolve({ ok: true, ...state, ...p }));
render(<PrivacySettings />);
const box = await screen.findByLabelText("Ignored folders");
await user.type(box, "/a/b{enter}{enter}/c");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(privacy.save).toHaveBeenCalledWith({ ignore_cwd: ["/a/b", "/c"] }));
});
});
@@ -0,0 +1,97 @@
/**
* @file ReportSettings.test.tsx
* @description Settings → Reports: a new report is saved with the browser's
* timezone offset, Run now runs it, and History lists the runs with download
* links built from the report and run ids.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { reports } = vi.hoisted(() => ({
reports: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
run: vi.fn(),
runs: vi.fn(),
fileUrl: (id: number, runId: number, f: string) => `/api/reports/${id}/runs/${runId}/${f}`,
},
}));
vi.mock("../../../lib/api", () => ({ api: { reports } }));
import { ReportSettings } from "../ReportSettings";
const DEF = {
id: 3,
name: "Weekly summary",
window_days: 7,
frequency: "weekly" as const,
hour: 8,
weekday: 1,
tz_offset: 0,
formats: ["html" as const, "json" as const],
enabled: true,
created_at: "2026-10-01T00:00:00Z",
last_slot: null,
last_run: null,
};
const RUN = {
id: 9,
report_id: 3,
created_at: "2026-10-05T01:00:00Z",
trigger: "manual" as const,
status: "ok" as const,
window_from: "2026-09-28",
window_to: "2026-10-04",
files: ["html", "json"],
error: null,
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("ReportSettings", () => {
it("creates a report with the browser's timezone offset", async () => {
const user = userEvent.setup();
reports.list.mockResolvedValueOnce({ reports: [] }).mockResolvedValue({ reports: [DEF] });
reports.create.mockResolvedValue({ report: DEF });
render(<ReportSettings />);
await user.click(await screen.findByRole("button", { name: /New report/ }));
await user.click(screen.getByRole("checkbox", { name: "JSON" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(reports.create).toHaveBeenCalledWith({
name: "Weekly summary",
window_days: 7,
frequency: "weekly",
hour: 8,
weekday: 1,
tz_offset: new Date().getTimezoneOffset(),
formats: ["html", "json"],
enabled: true,
})
);
expect(await screen.findByText(/last 7 days · HTML, JSON/)).toBeInTheDocument();
});
it("runs a report now and lists its runs with download links", async () => {
const user = userEvent.setup();
reports.list.mockResolvedValue({ reports: [DEF] });
reports.run.mockResolvedValue({ run: RUN });
reports.runs.mockResolvedValue({ runs: [RUN] });
render(<ReportSettings />);
await user.click(await screen.findByRole("button", { name: "Run now" }));
expect(reports.run).toHaveBeenCalledWith(3);
await user.click(screen.getByRole("button", { name: "History" }));
const link = await screen.findByRole("link", { name: /HTML/ });
expect(link).toHaveAttribute("href", "/api/reports/3/runs/9/html");
expect(screen.getByText(/2026-09-28 → 2026-10-04/)).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More