Compare commits

...

11 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
262 changed files with 18957 additions and 2689 deletions
+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. |
+13 -7
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -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
+26 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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 |
+11 -3
View File
@@ -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`.
@@ -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
+66 -14
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);
@@ -2797,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)"],
@@ -2917,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",
[ [
@@ -3664,8 +3671,43 @@ 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
@@ -3800,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":
+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"
+252 -12
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,14 +878,29 @@ export function AlertsNotifications() {
</span> </span>
)} )}
</label> </label>
<button <div className="flex items-center gap-2">
onClick={onSaveRule} <button
disabled={!canSubmit || saving} onClick={onPreview}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed" disabled={!canSubmit || previewing}
> title={t("rules.previewHint")}
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />} className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")} >
</button> <Eye className="w-3.5 h-3.5" />
{previewing ? t("rules.previewing") : t("rules.preview")}
</button>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
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" />
)}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</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>
);
}
+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})`,
+48 -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
@@ -97,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,
}; };
@@ -219,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);
@@ -242,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 {
@@ -591,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 }))}
@@ -674,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
@@ -689,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>
*/ */
+41 -8
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;
return () => window.clearInterval(timer); const tick = () => {
}, [kind, row, mood, reducedMotion]); timer = window.setTimeout(
() => {
f = (f + 1) % count;
setFrame(f);
tick();
},
f === count - 1 ? hold : step
);
};
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>
);
}
+288 -7
View File
@@ -11,22 +11,30 @@
* 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. Each figure carries its * whenever the URL names another workspace or session. Each figure carries its
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates * token total, and hovering it lists tokens and cost per model. At the right
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane. * 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 { useCallback, 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 type { CostResult } from "../lib/types"; import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format"; import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh"; 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");
@@ -64,6 +72,255 @@ function CostFigure({ testId, label, r }: { testId: string; label: string; r: Co
); );
} }
/** 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,
@@ -85,6 +342,8 @@ export function StatusBar({
const [laneCost, setLaneCost] = useState<CostResult | null>(null); const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<CostResult | 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;
@@ -124,6 +383,13 @@ export function StatusBar({
.totalCost({ date: localDate() }) .totalCost({ date: localDate() })
.then((r) => live && setToday(r)) .then((r) => live && setToday(r))
.catch(() => undefined); .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);
if (lane !== null) { if (lane !== null) {
api.pricing api.pricing
.totalCost({ lane }) .totalCost({ lane })
@@ -213,6 +479,21 @@ export function StatusBar({
r={sessionCost} 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>
); );
} }
+39 -19
View File
@@ -85,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";
@@ -264,22 +265,41 @@ 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(
let sawDown = !requireDownFirst; (requireDownFirst = false, fromVersion: string | null = null) => {
const attempt = () => { let sawDown = !requireDownFirst;
api.updates // Its own verdicts only: an older plugin-update.json is still on disk
.status() // until this chain writes one (5 s slack for a clock a little behind).
.then(() => { const since = Date.now() - 5000;
if (sawDown) window.location.reload(); const attempt = () => {
else setTimeout(attempt, 1500); api.updates
}) .status()
.catch(() => { .then((s) => {
sawDown = true; const v = s.last_plugin_update;
setTimeout(attempt, 1500); 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.
setTimeout(attempt, 1500); 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);
})
.catch(() => {
sawDown = true;
setTimeout(attempt, 1500);
});
};
setTimeout(attempt, 1500);
},
[syncFromPayload, t]
);
const applyNow = async () => { const applyNow = async () => {
if (applying || restarting) return; if (applying || restarting) return;
@@ -292,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(
@@ -330,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">
@@ -370,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>
@@ -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");
});
});
@@ -4,12 +4,13 @@
* 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 SESSION counts, today's spend (`date=` on the pricing cost call) * needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
* with its tokens and a per-model hover list, and on the Workspace page the * with its tokens and a per-model hover list, and on the Workspace page the
* open 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";
@@ -68,12 +69,63 @@ vi.mock("../../lib/api", () => ({
], ],
daily_costs: [], 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],
},
],
}),
},
}, },
})); }));
@@ -105,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();
@@ -144,4 +204,52 @@ 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 />);
@@ -88,7 +88,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
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: [] })) },
}, },
}; };
}); });
@@ -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>
+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"
/>
);
}
+304 -117
View File
@@ -7,6 +7,10 @@
* to open it * to open it
* review — every file's full diff, stacked, for reading a whole change-set * review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a * file — one file, as a diff or as its whole content (issue #35); a
* working file is always the editor (`DiffEditor`, diffed
* against HEAD / the branch's merge base, unified or side by
* side): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an * whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`) * image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff * history — recent commits; opening one shows that commit's diff
@@ -45,7 +49,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ChevronDown, ChevronDown,
@@ -57,6 +61,7 @@ import {
Code2, Code2,
Columns2, Columns2,
Eye, Eye,
EyeOff,
FileText, FileText,
FoldVertical, FoldVertical,
Folder, Folder,
@@ -77,12 +82,19 @@ import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed"; import { useArmed } from "../../hooks/useArmed";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView"; import {
LaneDiffView,
LaneFileView,
langFor,
type DiffLayout,
type LaneDiffChange,
} from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView"; import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
import type { import type {
LaneChangeFile, LaneChangeFile,
LaneChangeRange, LaneChangeRange,
@@ -136,6 +148,16 @@ function Delta({
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */ /** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i; const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited.
const DiffEditor = lazy(() => import("./DiffEditor"));
/** A file that opens straight in the editor: a working file with text — not a
* commit's, an image, a binary, a deleted file or a merge conflict (which
* opens in the conflict view). */
function editableFile(range: LaneChangeRange, path: string, f?: LaneChangeFile) {
if (range === "commit" || IMAGE_FILE.test(path)) return false;
return !f || (!f.binary && !f.conflicted && !f.status.includes("D"));
}
/** An image file, shown as itself; a broken one (deleted, too big) says so. */ /** An image file, shown as itself; a broken one (deleted, too big) says so. */
function ImageView({ src, path }: { src: string; path: string }) { function ImageView({ src, path }: { src: string; path: string }) {
@@ -198,10 +220,13 @@ function TreeNodes({
onOpenFile, onOpenFile,
onMenu, onMenu,
active, active,
kept,
}: { }: {
path: string; path: string;
depth: number; depth: number;
children: Record<string, LaneTreeEntry[]>; children: Record<string, LaneTreeEntry[]>;
/** Paths on the never-stage list: drawn orange, whatever their git status. */
kept?: Set<string>;
expanded: Set<string>; expanded: Set<string>;
onToggleDir: (path: string) => void; onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void; onOpenFile: (path: string) => void;
@@ -216,17 +241,28 @@ function TreeNodes({
<> <>
{entries.map((e) => { {entries.map((e) => {
const mark = e.status ? treeMark(e.status) : null; const mark = e.status ? treeMark(e.status) : null;
const isKept = !e.dir && !!kept?.has(e.path);
return ( return (
<li key={e.path}> <li key={e.path}>
<button <button
type="button" type="button"
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)} data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
data-tree-kept={isKept || undefined}
data-tree-ignored={e.ignored || undefined}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))} onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))} onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }} style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
aria-current={active === e.path ? "true" : undefined} aria-current={active === e.path ? "true" : undefined}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${ className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary" isKept
? "bg-kept/10 text-kept"
: e.ignored
? "text-kept"
: mark
? mark.tone
: e.changed
? "text-status-warning"
: "text-fg-primary"
} ${active === e.path ? "!bg-surface-4" : ""}`} } ${active === e.path ? "!bg-surface-4" : ""}`}
> >
{e.dir ? ( {e.dir ? (
@@ -268,6 +304,7 @@ function TreeNodes({
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
onMenu={onMenu} onMenu={onMenu}
active={active} active={active}
kept={kept}
/> />
</ul> </ul>
)} )}
@@ -322,12 +359,17 @@ function FileActions({
armed, armed,
onAct, onAct,
onArm, onArm,
kept = false,
onKeep,
}: { }: {
f: LaneChangeFile; f: LaneChangeFile;
busy: boolean; busy: boolean;
armed: boolean; armed: boolean;
onAct: FileAct; onAct: FileAct;
onArm: (path: string) => void; onArm: (path: string) => void;
/** On the never-stage list: Stage all and a folder's stage skip it. */
kept?: boolean;
onKeep?: (path: string) => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
return ( return (
@@ -350,6 +392,14 @@ function FileActions({
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</IconButton> </IconButton>
) : null} ) : null}
{onKeep && (kept || f.unstaged || f.untracked) ? (
<IconButton
label={t(kept ? "changes.allowStage" : "changes.neverStage")}
onClick={() => onKeep(f.path)}
>
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
</IconButton>
) : null}
<IconButton <IconButton
label={armed ? t("changes.discardConfirm") : t("changes.discard")} label={armed ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy} disabled={busy}
@@ -425,8 +475,12 @@ function ChangeRow({
onMenu, onMenu,
name, name,
depth = 0, depth = 0,
kept = false,
onKeep,
}: { }: {
f: LaneChangeFile; f: LaneChangeFile;
kept?: boolean;
onKeep?: (path: string) => void;
/** In the folder tree: the file's own name, and how deep it sits. */ /** In the folder tree: the file's own name, and how deep it sits. */
name?: string; name?: string;
depth?: number; depth?: number;
@@ -444,7 +498,9 @@ function ChangeRow({
return ( return (
<li <li
className="group flex items-baseline gap-1" className="group flex items-baseline gap-1"
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined} // A file has no chevron: reserve its 1rem so the letter lines up
// with a sibling folder's icon, one step inside the parent folder.
style={{ paddingLeft: `${depth + 1}rem` }}
onContextMenu={(e) => onMenu(e, f)} onContextMenu={(e) => onMenu(e, f)}
> >
<button <button
@@ -461,8 +517,10 @@ function ChangeRow({
{f.untracked ? "A" : f.status.trim()[0] || "M"} {f.untracked ? "A" : f.status.trim()[0] || "M"}
</span> </span>
<span <span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary" className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
title={f.path} kept ? "text-fg-muted line-through" : "text-fg-primary"
}`}
title={kept ? `${f.path}\n${t("changes.keptHint")}` : f.path}
> >
{name ?? f.path} {name ?? f.path}
</span> </span>
@@ -475,7 +533,15 @@ function ChangeRow({
(f.conflicted ? ( (f.conflicted ? (
<ConflictActions f={f} busy={busy} onResolve={onResolve} /> <ConflictActions f={f} busy={busy} onResolve={onResolve} />
) : ( ) : (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} /> <FileActions
f={f}
busy={busy}
armed={armed}
onAct={onAct}
onArm={onArm}
kept={kept}
onKeep={onKeep}
/>
))} ))}
</li> </li>
); );
@@ -510,10 +576,7 @@ function ChangeTreeNodes({
const open = !collapsed.has(key); const open = !collapsed.has(key);
return ( return (
<li key={d.path}> <li key={d.path}>
<div <div className="group flex items-center gap-1" style={{ paddingLeft: `${depth}rem` }}>
className="group flex items-center gap-1"
style={{ paddingLeft: `${depth * 0.9}rem` }}
>
<button <button
type="button" type="button"
aria-expanded={open} aria-expanded={open}
@@ -559,11 +622,56 @@ function ChangeTreeNodes({
const HISTORY_PAGE = 50; const HISTORY_PAGE = 50;
const HISTORY_MAX = 200; const HISTORY_MAX = 200;
// The history's commit graph: one fixed-height row per commit so its lanes
// join up row to row. Mid-tone hues that read on dark and light themes alike.
const GRAPH_ROW = 34;
const GRAPH_COL = 12;
const GRAPH_COLORS = [
"#3b82f6",
"#22c55e",
"#f59e0b",
"#a855f7",
"#ef4444",
"#14b8a6",
"#ec4899",
"#84cc16",
];
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
return (
<svg
aria-hidden
width={width * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
</svg>
);
}
export function LaneChangesPanel({ export function LaneChangesPanel({
laneId, laneId,
session, session,
onNewLane, onNewLane,
full = false, full = false,
wide = full,
onDirtyChange, onDirtyChange,
}: { }: {
laneId: number; laneId: number;
@@ -578,6 +686,8 @@ export function LaneChangesPanel({
/** The panel has the whole width: the file tree becomes a sidebar and a /** The panel has the whole width: the file tree becomes a sidebar and a
* whole file opens straight into the editor. */ * whole file opens straight into the editor. */
full?: boolean; full?: boolean;
/** Half the width or more: diffs and the editor open side by side. */
wide?: boolean;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below: // One read of the working copy, shared by the header and every write below:
@@ -607,7 +717,10 @@ export function LaneChangesPanel({
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null; const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
const [sha, setSha] = useState<string | null>(null); const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]); const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified"); // Side by side wherever there is room for two columns; a resize starts over
// from that default, a click on the toggle overrides it until then.
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false); const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file. // A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false); const [mdPreview, setMdPreview] = useState(false);
@@ -630,33 +743,17 @@ export function LaneChangesPanel({
// The whole-file editor's text while it is open; null = viewing. // The whole-file editor's text while it is open; null = viewing.
const [draft, setDraft] = useState<string | null>(null); const [draft, setDraft] = useState<string | null>(null);
const editWanted = useRef(false); const editWanted = useRef(false);
// Where the editor opens: the line clicked in the read view, and that view's // The text the editor diffs against: the file at HEAD (`work`) or at the
// scroll offset, so the text does not jump when the textarea replaces it. // branch's merge base; "" for a file that is new there.
const editAt = useRef<{ line: number; scrollTop: number } | null>(null); const [baseText, setBaseText] = useState<string | null>(null);
const editorRef = useRef<HTMLTextAreaElement>(null); const startEdit = () => {
const editing = draft !== null;
useEffect(() => {
const at = editAt.current;
const el = editorRef.current;
if (!editing || !at || !el) return;
editAt.current = null;
const offset = el.value
.split("\n")
.slice(0, at.line)
.reduce((n, l) => n + l.length + 1, 0);
el.setSelectionRange(offset, offset);
el.focus({ preventScroll: true });
el.scrollTop = at.scrollTop;
}, [editing]);
const startEdit = (line: number, scrollTop: number) => {
if (!text?.sha) return; if (!text?.sha) return;
editAt.current = { line, scrollTop };
setDraft(text.lines.join("\n")); setDraft(text.lines.join("\n"));
}; };
useEffect(() => { useEffect(() => {
if (!editWanted.current || !text) return; if (!editWanted.current || !text) return;
editWanted.current = false; editWanted.current = false;
if (text.sha) startEdit(0, 0); if (text.sha) startEdit();
// Asked for from the diff, refused once the file arrived: say why here, // Asked for from the diff, refused once the file arrived: say why here,
// not with an Edit button that silently does nothing. // not with an Edit button that silently does nothing.
else setError(t(noEditReason(text))); else setError(t(noEditReason(text)));
@@ -692,6 +789,32 @@ export function LaneChangesPanel({
if (!next.delete(key)) next.add(key); if (!next.delete(key)) next.add(key);
return next; return next;
}); });
// Files the user never wants staged (a local note, a scratch folder): Stage
// all and a folder's stage skip them; git is never told — nothing is written
// to the repo. A viewer preference per workspace, like the panel's size.
const neverStageKey = `agenthub.changes.neverStage.${laneId}`;
const [neverStage, setNeverStage] = useState<Set<string>>(new Set());
useEffect(() => {
try {
const saved = JSON.parse(window.localStorage.getItem(neverStageKey) || "[]");
setNeverStage(
new Set(Array.isArray(saved) ? saved.filter((p) => typeof p === "string") : [])
);
} catch {
setNeverStage(new Set());
}
}, [neverStageKey]);
const toggleNeverStage = (path: string) =>
setNeverStage((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
try {
window.localStorage.setItem(neverStageKey, JSON.stringify([...next]));
} catch {
/* kept for this visit only */
}
return next;
});
// Where opening a file came FROM: its mode and how far the list was scrolled. // Where opening a file came FROM: its mode and how far the list was scrolled.
// Reviewing a change-set is a walk down a long list, and landing back at the // Reviewing a change-set is a walk down a long list, and landing back at the
// top of it after every file is the whole cost of opening one. // top of it after every file is the whole cost of opening one.
@@ -846,10 +969,15 @@ export function LaneChangesPanel({
}; };
}, [mode, laneId, query, full]); }, [mode, laneId, query, full]);
// The open file's entry in the change list, and whether it is edited in
// place: a working file with text opens as the editor in both views.
const openEntry =
data?.available && openFile ? data.files.find((f) => f.path === openFile) : undefined;
const editable = mode === "file" && !!openFile && editableFile(range, openFile, openEntry);
// The whole-file read is a different endpoint from the diff, so it is its // The whole-file read is a different endpoint from the diff, so it is its
// own fetch — and only while the viewer is actually showing it. // own fetch — and only while the viewer is actually showing it.
useEffect(() => { useEffect(() => {
if (mode !== "file" || !whole || !openFile) return; if (mode !== "file" || !(whole || editable) || !openFile) return;
void api.lanes void api.lanes
.gitFile(target, openFile, range === "commit" && sha ? sha : "work") .gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then( .then(
@@ -868,17 +996,22 @@ export function LaneChangesPanel({
) )
) )
.catch((err) => setError(err instanceof Error ? err.message : String(err))); .catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]); }, [mode, whole, editable, openFile, laneId, range, sha]);
// The editor's original side, read beside the working file. A file that is
// not there (new since) diffs against nothing: every line is added.
const mergeBase = data?.available ? (data.mergeBase ?? null) : null;
useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
void api.lanes.gitFile(target, openFile, rev).then(
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
forLane(laneId, () => setBaseText(""))
);
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
useEffect(() => { useEffect(() => {
if (full && mode === "browse") setMode("overview"); if (full && mode === "browse") setMode("overview");
}, [full, mode]); }, [full, mode]);
// Going full width with a working file already open: straight to the editor.
useEffect(() => {
if (full && mode === "file" && range !== "commit") setWhole(true);
// Only on the switch itself — Whole file must still toggle afterwards.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [full]);
// A lane switch must not leave the previous lane's file open — its path // A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one. // almost certainly does not exist in the new one.
@@ -939,10 +1072,14 @@ export function LaneChangesPanel({
} }
setOpenFile(path); setOpenFile(path);
setContext(CONTEXT_STEPS[0]); setContext(CONTEXT_STEPS[0]);
// Full width edits by default: a working file opens as the editor, and // A file opens on its Changes. A working file is the editor folded to its
// Whole file toggles back to its diff. A commit is history, so a diff. // changes (each block with a Revert); Whole file unfolds it. Anything else
setWhole(full && range !== "commit"); // — a commit, a binary, a deleted file — is the read-only diff.
const f = data?.available ? data.files.find((x) => x.path === path) : undefined;
editWanted.current = editableFile(range, path, f);
setWhole(false);
setText(null); setText(null);
setBaseText(null);
setDraft(null); setDraft(null);
setRawEdit(false); setRawEdit(false);
setMode("file"); setMode("file");
@@ -978,7 +1115,8 @@ export function LaneChangesPanel({
if (r.available) { if (r.available) {
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha }); setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
} }
setDraft(null); // Saved, still editing: the editor stays on the text just written.
setDraft(r.available ? r.lines.join("\n") : null);
refreshGit(); refreshGit();
await load(); await load();
} catch (err) { } catch (err) {
@@ -1105,6 +1243,9 @@ export function LaneChangesPanel({
{t("changes.unstage")} {t("changes.unstage")}
</MenuItem> </MenuItem>
)} )}
<MenuItem onSelect={pick(fileCtx.close, () => toggleNeverStage(menuFile.path))}>
{t(neverStage.has(menuFile.path) ? "changes.allowStage" : "changes.neverStage")}
</MenuItem>
<MenuItem <MenuItem
danger danger
disabled={busy} disabled={busy}
@@ -1303,7 +1444,9 @@ export function LaneChangesPanel({
// What "Stage all" sends: every listed file with a change the index lacks. // What "Stage all" sends: every listed file with a change the index lacks.
// Never a conflicted one — staging it would mark it resolved. // Never a conflicted one — staging it would mark it resolved.
const toStage = const toStage =
range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : []; range === "work"
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
: [];
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined; const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
// The armed path left the list (committed, reverted, lane switched): disarm, // The armed path left the list (committed, reverted, lane switched): disarm,
@@ -1314,11 +1457,17 @@ export function LaneChangesPanel({
} }
}, [data, files, confirmDiscard]); }, [data, files, confirmDiscard]);
// A tree or quick-open pick opens the whole file — and, with an edit open, // A tree or quick-open pick opens a changed file on its Changes like any
// would drop it, so a dirty draft asks first. // other, and an unchanged one — no diff to show — as the whole file, in the
// editor. With an edit open it would drop it, so a dirty draft asks first.
const openWhole = async (p: string) => { const openWhole = async (p: string) => {
if (!(await confirmDrop())) return; if (!(await confirmDrop())) return;
showNow(p); showNow(p);
// The overview's list, or — with one file open, the list is that file —
// the working-copy facts' (capped) list of changed paths.
const changed = (git?.available ? git.files : []).map((f) => f.path);
if ([...files.map((f) => f.path), ...changed].includes(p)) return;
editWanted.current = editableFile(range, p);
setWhole(true); setWhole(true);
}; };
@@ -1384,6 +1533,7 @@ export function LaneChangesPanel({
onOpenFile={(p) => void openWhole(p)} onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open} onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null} active={full && mode === "file" ? openFile : null}
kept={neverStage}
/> />
</ul> </ul>
)} )}
@@ -1531,7 +1681,7 @@ export function LaneChangesPanel({
file to get the tight view back. */} file to get the tight view back. */}
<IconButton <IconButton
label={t("changes.collapseContext")} label={t("changes.collapseContext")}
disabled={whole || context <= CONTEXT_STEPS[0]} disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
onClick={() => onClick={() =>
setContext( setContext(
[...CONTEXT_STEPS].reverse().find((step) => step < context) ?? [...CONTEXT_STEPS].reverse().find((step) => step < context) ??
@@ -1543,7 +1693,7 @@ export function LaneChangesPanel({
</IconButton> </IconButton>
<IconButton <IconButton
label={t("changes.expandContext")} label={t("changes.expandContext")}
disabled={whole || context >= LAST_CONTEXT} disabled={whole || draft !== null || context >= LAST_CONTEXT}
onClick={() => onClick={() =>
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context) setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
} }
@@ -1556,17 +1706,35 @@ export function LaneChangesPanel({
)} )}
{mode === "file" && ( {mode === "file" && (
<button <div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
type="button" {([false, true] as const).map((w) => (
aria-pressed={whole} <button
disabled={dirty} key={String(w)}
onClick={() => setWhole(!whole)} type="button"
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${ aria-pressed={whole === w}
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2" onClick={() => {
}`} if (whole === w) return;
> // Both views of a working file are the same editor: the
{t("changes.wholeFile")} // draft goes along, unsaved or not.
</button> if (editable && draft !== null) return setWhole(w);
// Back to Changes drops an open edit, so unsaved text asks first.
void confirmDrop().then((ok) => {
if (!ok) return;
if (w) editWanted.current = editableFile(range, openFile ?? "", current);
else setDraft(null);
setWhole(w);
});
}}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
whole === w
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(w ? "changes.wholeFile" : "changes.changesView")}
</button>
))}
</div>
)} )}
</div> </div>
)} )}
@@ -1622,30 +1790,38 @@ export function LaneChangesPanel({
}`} }`}
> >
{mode === "history" ? ( {mode === "history" ? (
<ul data-testid="lane-changes-history" className="space-y-0.5"> <ul data-testid="lane-changes-history">
{(commits ?? []).map((c) => ( {(() => {
<li key={c.sha}> const list = commits ?? [];
<button const graph = commitGraph(list);
type="button" return list.map((c, i) => (
onClick={() => viewCommit(c.sha)} <li key={c.sha}>
onContextMenu={(e) => commitCtx.open(e, c)} <button
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2" type="button"
> onClick={() => viewCommit(c.sha)}
<span className="flex items-baseline gap-1.5"> onContextMenu={(e) => commitCtx.open(e, c)}
<span className="shrink-0 font-mono text-[10px] text-fg-muted"> style={{ height: GRAPH_ROW }}
{c.short} className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<GraphCell row={graph.rows[i]!} width={graph.width} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</span> </span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"> </button>
{c.subject} </li>
</span> ));
</span> })()}
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</button>
</li>
))}
{commits === null && ( {commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li> <li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)} )}
@@ -1831,6 +2007,8 @@ export function LaneChangesPanel({
onArm={setConfirmDiscard} onArm={setConfirmDiscard}
onResolve={resolve} onResolve={resolve}
onMenu={fileCtx.open} onMenu={fileCtx.open}
kept={neverStage.has(f.path)}
onKeep={toggleNeverStage}
/> />
)} )}
folderAction={ folderAction={
@@ -1846,7 +2024,11 @@ export function LaneChangesPanel({
onClick={() => onClick={() =>
void act( void act(
g.key === "staged" ? "unstage" : "stage", g.key === "staged" ? "unstage" : "stage",
rows.map((f) => f.path) rows
.filter(
(f) => g.key === "staged" || !neverStage.has(f.path)
)
.map((f) => f.path)
) )
} }
> >
@@ -1898,7 +2080,7 @@ export function LaneChangesPanel({
binary={current.binary} binary={current.binary}
/> />
)} )}
{whole && text && draft === null && isMarkdown(openFile) && ( {whole && text && !dirty && isMarkdown(openFile) && (
<button <button
type="button" type="button"
data-testid="lane-file-preview" data-testid="lane-file-preview"
@@ -1911,9 +2093,8 @@ export function LaneChangesPanel({
</button> </button>
)} )}
{/* Only the working file is editable: a commit is history. */} {/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text; {/* A working file opens in the editor; the read-only diff
only the diff, which has no whole text to click, keeps (Whole file off) keeps an Edit button back into it. */}
an Edit button. */}
{range !== "commit" && {range !== "commit" &&
!openImage && !openImage &&
(draft === null && whole && text && !text.sha ? ( (draft === null && whole && text && !text.sha ? (
@@ -1923,14 +2104,7 @@ export function LaneChangesPanel({
> >
{t(noEditReason(text))} {t(noEditReason(text))}
</span> </span>
) : draft === null && whole ? ( ) : draft === null && whole ? null : draft === null ? (
text &&
!mdPreview && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</span>
)
) : draft === null ? (
<button <button
type="button" type="button"
data-testid="lane-file-edit" data-testid="lane-file-edit"
@@ -1959,8 +2133,13 @@ export function LaneChangesPanel({
</button> </button>
<button <button
type="button" type="button"
disabled={busy} data-testid="lane-file-cancel"
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))} disabled={busy || !dirty}
onClick={() =>
void confirmDrop().then(
(ok) => ok && setDraft(text ? text.lines.join("\n") : null)
)
}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40" className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
> >
{t("changes.cancelEdit")} {t("changes.cancelEdit")}
@@ -1977,23 +2156,32 @@ export function LaneChangesPanel({
range === "commit" && sha ? sha : "work" range === "commit" && sha ? sha : "work"
)} )}
/> />
) : whole && draft !== null ? ( ) : !mdPreview &&
<textarea (whole || editable) &&
ref={editorRef} (draft !== null || (editable && text === null)) ? (
data-testid="lane-file-editor" // The editor, once both texts are in: not the diff first.
aria-label={t("changes.editorLabel", { path: openFile })} draft === null || (baseText === null && range !== "commit") ? (
value={draft} <p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
onChange={(e) => setDraft(e.target.value)} ) : (
onKeyDown={(e) => { <Suspense
if ((e.ctrlKey || e.metaKey) && e.key === "s") { fallback={<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>}
e.preventDefault(); >
if (!busy) void saveDraft(); <DiffEditor
} key={`${openFile}:${layout}:${whole}`}
}} layout={layout}
spellCheck={false} collapse={!whole}
wrap="off" revertLabel={t("changes.revertBlock")}
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none" value={draft}
/> original={baseText ?? draft}
lang={langFor(openFile ?? "")}
label={t("changes.editorLabel", { path: openFile })}
onChange={setDraft}
onSave={() => {
if (!busy) void saveDraft();
}}
/>
</Suspense>
)
) : whole ? ( ) : whole ? (
text && mdPreview ? ( text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4"> <div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
@@ -2005,7 +2193,6 @@ export function LaneChangesPanel({
lines={text.lines} lines={text.lines}
truncated={text.truncated} truncated={text.truncated}
fill fill
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
/> />
) : ( ) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p> <p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
+24 -44
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,12 +962,9 @@ 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. */} <Gutter n={i + 1} />
<span data-ln={i} className="contents"> <span className="whitespace-pre px-2">
<Gutter n={i + 1} /> <Code text={text} lang={lang} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</span> </span>
</Row> </Row>
); );
@@ -972,27 +972,7 @@ export function LaneFileView({
<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}>
+51 -27
View File
@@ -1,9 +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) and the git action bar under it * head, subject) and the git action bar under it (`LaneGitBar`); plus the
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes * uncommitted counts drawn vertical on the CLOSED Changes rail
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel — * (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
* the open panel lists those files itself, so its header chip opens nothing. * 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.
@@ -102,6 +104,7 @@ const GIT_STATUS_STYLE: Record<string, string> = {
function LaneGitFilesPanel({ function LaneGitFilesPanel({
files, files,
hidden, hidden,
onShowAll,
anchor, anchor,
side = "below", side = "below",
onPointerEnter, onPointerEnter,
@@ -109,6 +112,8 @@ function LaneGitFilesPanel({
}: { }: {
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. */ /** `left` beside a chip on the right-edge rail, where below would cover it. */
@@ -146,13 +151,14 @@ function LaneGitFilesPanel({
// 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}
@@ -169,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>
@@ -206,30 +212,40 @@ function LaneGitFilesPanel({
); );
})} })}
</ul> </ul>
{hidden > 0 && ( {hidden > 0 &&
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted"> (onShowAll ? (
{t("git.filesMore", { count: hidden })} <button
</p> 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">
{t("git.filesMore", { count: hidden })}
</p>
))}
</div>, </div>,
document.body document.body
); );
} }
/** /**
* The uncommitted counts as a chip. With `details` it opens the per-file panel * The uncommitted counts as a chip that opens the per-file panel on hover,
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing * focus or click — the closed Changes rail, `vertical`, where nothing else
* else lists the files. In the open panel the list below already does, so the * lists the files.
* header shows the chip alone.
*/ */
export function LaneGitCounts({ export function LaneGitCounts({
git, git,
vertical = false, vertical = false,
details = true, onShowAll,
}: { }: {
git: Extract<LaneGitFacts, { available: true }>; git: Extract<LaneGitFacts, { available: true }>;
vertical?: boolean; vertical?: boolean;
details?: boolean; /** See `LaneGitFilesPanel`. */
onShowAll?: () => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false); const [filesOpen, setFilesOpen] = useState(false);
@@ -253,13 +269,6 @@ export function LaneGitCounts({
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${ 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" vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
}`; }`;
if (!details) {
return (
<span data-testid="lane-git-counts" className={chip}>
{label}
</span>
);
}
return ( return (
<> <>
<button <button
@@ -288,6 +297,13 @@ export function LaneGitCounts({
<LaneGitFilesPanel <LaneGitFilesPanel
files={git.files} files={git.files}
hidden={git.dirty + git.untracked - git.files.length} hidden={git.dirty + git.untracked - git.files.length}
onShowAll={
onShowAll &&
(() => {
setFilesOpen(false);
onShowAll();
})
}
anchor={anchor} anchor={anchor}
side={vertical ? "left" : "below"} side={vertical ? "left" : "below"}
onPointerEnter={openFiles} onPointerEnter={openFiles}
@@ -300,9 +316,18 @@ export function LaneGitCounts({
/** The counts on the closed Changes rail: its own facts read, polled only /** 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). */ * while the rail is shown (the open panel owns the read otherwise). */
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) { 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); const [git] = useLaneGitFacts(laneId, session);
return git?.available ? <LaneGitCounts git={git} vertical /> : null; return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
} }
export function LaneGitHeader({ export function LaneGitHeader({
@@ -339,7 +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>
<LaneGitCounts git={git} details={false} />
</> </>
); );
return ( return (
@@ -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 =
+34 -18
View File
@@ -7,7 +7,7 @@
* `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";
@@ -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>
+1 -1
View File
@@ -160,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 && (
@@ -66,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
@@ -437,7 +438,7 @@ export function WorkspaceSessionList({
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) { 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}
</> </>
@@ -445,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
@@ -460,7 +465,7 @@ export function WorkspaceSessionList({
} }
onContextMenu={(e) => ctx.open(e, r)} onContextMenu={(e) => ctx.open(e, r)}
title={`${r.tool === "shell" ? t("tabs.terminal") : 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"
@@ -472,7 +477,7 @@ export function WorkspaceSessionList({
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")} {r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span> </span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted"> <span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt) : null} {r.startedAt ? timeAgo(r.startedAt, true) : null}
</span> </span>
{r.tool === "shell" ? ( {r.tool === "shell" ? (
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" /> <SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
@@ -490,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")}
@@ -544,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"
@@ -564,12 +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] text-fg-muted"> <span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)} {timeAgo(s.last_activity ?? s.started_at, true)}
</span> </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="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
@@ -595,7 +600,7 @@ export function WorkspaceSessionList({
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)} onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)} onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")} title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary" 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 <span
aria-hidden aria-hidden
@@ -604,7 +609,7 @@ export function WorkspaceSessionList({
<span className="sr-only">{t("tree.state.idle")}</span> <span className="sr-only">{t("tree.state.idle")}</span>
<span className="truncate">{t("tabs.terminal")}</span> <span className="truncate">{t("tabs.terminal")}</span>
<span className="ml-auto shrink-0 text-[11px]"> <span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)} {timeAgo(r.ended_at ?? r.started_at, true)}
</span> </span>
<SquareTerminal <SquareTerminal
aria-hidden aria-hidden
@@ -629,7 +634,7 @@ export function WorkspaceSessionList({
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>
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
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() },
}, },
@@ -50,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,
@@ -62,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 }),
@@ -158,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)", () => {
@@ -639,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: [] });
@@ -19,6 +19,36 @@ 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),
@@ -90,6 +120,11 @@ 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.getByTitle(path).closest("li")!).getByRole("button", { return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
@@ -98,6 +133,7 @@ function discardOf(path: string) {
} }
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());
@@ -113,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: [],
}, },
], ],
}); });
@@ -192,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");
@@ -247,6 +285,45 @@ 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 () => { it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
@@ -393,12 +470,12 @@ 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("shows an image file as the picture, with no edit control", async () => { it("shows an image file as the picture, with no edit control", async () => {
@@ -427,7 +504,7 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull(); expect(screen.queryByTestId("lane-file-edit")).toBeNull();
}); });
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => { 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: "",
@@ -452,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",
@@ -488,31 +565,40 @@ describe("LaneChangesPanel", () => {
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).getByTitle("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).getByTitle("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 () => {
@@ -640,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(() =>
@@ -785,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);
@@ -798,9 +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;");
}); });
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 () => {
@@ -844,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"));
@@ -885,6 +970,7 @@ describe("LaneChangesPanel", () => {
}) })
); );
await user.click(screen.getByTitle("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" }));
@@ -896,6 +982,7 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTitle("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
@@ -926,7 +1013,7 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTitle("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(() =>
@@ -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", () => {
@@ -49,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} />);
@@ -57,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 () => {
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
render(<LaneGitRailCounts 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("shows the counts in the open panel's header without the hover list", async () => { 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); vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />); render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts"); await screen.findByTestId("lane-git");
fireEvent.mouseEnter(counts); expect(screen.queryByTestId("lane-git-counts")).toBeNull();
fireEvent.focus(counts);
expect(screen.queryByTestId("lane-git-files")).toBeNull();
}); });
it("draws the rail's counts vertical", async () => { it("draws the rail's counts vertical", 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,7 +193,9 @@ 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, 4, 5, 6, 7, 8, 9].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(1, all.slice(0, 4))).toBe(4);
expect(freeSlot(2, all)).toBe(0); expect(freeSlot(2, all)).toBe(0);
@@ -222,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) =>
@@ -233,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"),
]); ]);
@@ -244,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);
@@ -270,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: {} },
{ {
@@ -297,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" })
); );
}); });
@@ -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();
});
});
+2 -8
View File
@@ -14,7 +14,7 @@
* toasts. The `+` menu opens a new tab in the free slot the server picks * toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`) * (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no * or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, router or worktree is still the workspace home's prompt * prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit * box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the * (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus) * workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -51,13 +51,7 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession"; import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid"; import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage"; import { insertionIndex } from "../../lib/laneOrderStorage";
import { import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
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>;
+25 -17
View File
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
return [current.groups, 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,
@@ -323,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]));
@@ -391,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);
@@ -404,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);
@@ -417,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) => {
@@ -561,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=""
@@ -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,67 +81,99 @@ export function AppearanceSettings() {
}; };
return ( return (
<div className="card p-5 space-y-5"> <div className="space-y-6">
<SettingsRow <SettingsSection
label={t("appearance.mode")} id="language"
control={ title={t("language.title")}
<SettingsSegmented description={t("language.description")}
ariaLabel={t("appearance.mode")} >
value={mode} <SettingsRow
onChange={setMode} label={t("language.label")}
options={MODES.map(({ mode: m, Icon }) => ({ control={
value: m, <SettingsSegmented
label: ( ariaLabel={t("language.label")}
<> value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
<Icon className="w-3.5 h-3.5" /> onChange={(v) => void i18n.changeLanguage(v)}
{t(`appearance.modes.${m}`)} options={[
</> { value: "en" as const, label: t("language.names.en") },
), { value: "vi" as const, label: t("language.names.vi") },
}))} ]}
/> />
} }
/> />
</SettingsSection>
{(["dark", "light"] as const).map((group) => ( <section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<div key={group}> <h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<p className="text-xs font-medium text-fg-secondary mb-2"> <SettingsRow
{t(`appearance.${group}Themes`)} label={t("appearance.mode")}
{theme === group && ( control={
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span> <SettingsSegmented
)} ariaLabel={t("appearance.mode")}
</p> value={mode}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> onChange={setMode}
{THEMES[group].map((name) => { options={MODES.map(({ mode: m, Icon }) => ({
const selected = themeFor[group] === name; value: m,
const label = THEME_LABELS[name] ?? t("appearance.default"); label: (
return ( <>
<button <Icon className="w-3.5 h-3.5" />
key={name} {t(`appearance.modes.${m}`)}
type="button" </>
onClick={() => setThemeFor(group, name)} ),
aria-pressed={selected} }))}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`} />
className={`rounded-lg border p-1.5 text-left transition-colors ${ }
selected />
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light" {(["dark", "light"] as const).map((group) => (
}`} <div key={group}>
> <p className="text-xs font-medium text-fg-secondary mb-2">
<Swatch mode={group} name={name} /> {t(`appearance.${group}Themes`)}
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span> {theme === group && (
</button> <span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
); )}
})} </p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div> </div>
</div> ))}
))}
<div className="flex justify-end border-t border-border pt-3"> <div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs"> <button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
{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>
);
}
@@ -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
@@ -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
@@ -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();
});
});
+33 -5
View File
@@ -7,20 +7,26 @@
"event_pattern": "Event pattern", "event_pattern": "Event pattern",
"inactivity": "Inactivity", "inactivity": "Inactivity",
"status_duration": "Stuck agent", "status_duration": "Stuck agent",
"token_threshold": "Token threshold" "token_threshold": "Token threshold",
"cost_threshold": "Cost threshold",
"budget": "Spend budget"
}, },
"ruleTypeHints": { "ruleTypeHints": {
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).", "event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
"inactivity": "Fires when an active session has received no events for the configured number of minutes.", "inactivity": "Fires when an active session has received no events for the configured number of minutes.",
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.", "status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold." "token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
}, },
"ruleDesc": { "ruleDesc": {
"eventPattern": "Matches {{pattern}}", "eventPattern": "Matches {{pattern}}",
"eventPatternCount": "≥{{count}} × {{pattern}} within {{window}} min", "eventPatternCount": "≥{{count}} × {{pattern}} within {{window}} min",
"inactivity": "No session activity for {{minutes}} min", "inactivity": "No session activity for {{minutes}} min",
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min", "statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
"tokenThreshold": "Session tokens exceed {{tokens}}" "tokenThreshold": "Session tokens exceed {{tokens}}",
"costThreshold": "Session cost reaches {{usd}}",
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
}, },
"rules": { "rules": {
"title": "Alert rules", "title": "Alert rules",
@@ -48,7 +54,13 @@
"agentStatus": "Agent status", "agentStatus": "Agent status",
"minutes": "Minutes", "minutes": "Minutes",
"totalTokens": "Total tokens", "totalTokens": "Total tokens",
"cooldown": "Cooldown (seconds)" "cooldown": "Cooldown (seconds)",
"usd": "Cost (USD)",
"period": "Period",
"limitUsd": "Limit (USD)",
"thresholds": "Alert at (%)",
"budgetTool": "AI tool",
"allTools": "All AI tools"
}, },
"deleteTitle": "Delete alert rule?", "deleteTitle": "Delete alert rule?",
"help": { "help": {
@@ -63,8 +75,19 @@
"minutesInactivity": "Fire when an active session has had no events for this many minutes (a stalled or forgotten session).", "minutesInactivity": "Fire when an active session has had no events for this many minutes (a stalled or forgotten session).",
"minutesStatus": "Fire when an agent stays in the selected status with no activity for this many minutes (a stuck agent).", "minutesStatus": "Fire when an agent stays in the selected status with no activity for this many minutes (a stuck agent).",
"totalTokens": "Fire when a session's cumulative tokens (input + output + cache read + cache write) cross this number. e.g. 1000000 = 1M tokens.", "totalTokens": "Fire when a session's cumulative tokens (input + output + cache read + cache write) cross this number. e.g. 1000000 = 1M tokens.",
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown." "cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown.",
"usd": "A session reaching this many US dollars fires the alert.",
"period": "Day starts at your local midnight, week on Monday, month on the 1st.",
"limitUsd": "The spend limit for one period, in US dollars.",
"thresholds": "Percents of the limit, comma-separated, e.g. 50, 80, 100. Each fires once per period.",
"budgetTool": "Count only this AI tool's sessions."
}, },
"preview": "Preview",
"previewing": "Checking…",
"previewHint": "What this rule would have fired over the last 7 days — nothing is saved or sent",
"previewResult": "Would have fired {{fires}} times in {{sessions}} sessions over the last {{days}} days.",
"previewBudget": "Spend per period (this one and the five before it):",
"previewUnsupported": "This rule type cannot be replayed from history.",
"edit": "Edit rule", "edit": "Edit rule",
"save": "Save rule", "save": "Save rule",
"typeFixed": "A rule's type cannot be changed — create a new rule instead.", "typeFixed": "A rule's type cannot be changed — create a new rule instead.",
@@ -81,6 +104,11 @@
"emptyDescription": "Alerts fired by your rules will appear here in real time.", "emptyDescription": "Alerts fired by your rules will appear here in real time.",
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire." "emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire."
}, },
"periods": {
"day": "day",
"week": "week",
"month": "month"
},
"errors": { "errors": {
"loadRules": "Could not load alert rules: {{error}}", "loadRules": "Could not load alert rules: {{error}}",
"loadAlerts": "Could not load alerts: {{error}}", "loadAlerts": "Could not load alerts: {{error}}",
+50
View File
@@ -51,6 +51,56 @@
"last7Days": "Last 7 days", "last7Days": "Last 7 days",
"less": "Less", "less": "Less",
"more": "More", "more": "More",
"toolFilter": "AI tool",
"allTools": "All AI tools",
"costByTool": "Cost by AI tool",
"spendPace": "Spend pace",
"burnRate": "Burn rate (7-day avg / day)",
"burnRateHint": "Average cost per day over the last 7 days, today included",
"monthToDate": "Month to date",
"forecastMonth": "Forecast for this month",
"forecastHint": "Month to date plus the 7-day pace for each day left",
"budgets": "Budgets",
"noBudgets": "No spend budget yet.",
"addBudget": "Add one in Settings → Notifications",
"projected": "projected",
"projectedHint": "This period's spend at the current pace, carried to its end",
"range": "Date range",
"rangePreset": {
"today": "Today",
"week": "This week",
"month": "This month",
"7d": "Last 7 days",
"30d": "Last 30 days",
"90d": "Last 90 days",
"all": "All time",
"custom": "Custom"
},
"rangeFrom": "From",
"rangeTo": "To",
"query": {
"entities": {
"agents": "Agents"
},
"search": "Search",
"searchPlaceholder": "Search text…",
"status": "Status",
"allStatuses": "All statuses",
"cwd": "Folder",
"cwdPlaceholder": "/path/to/project",
"from": "From",
"to": "To",
"sortBy": "Sort by {{column}}",
"exportCsv": "Export CSV",
"exportJson": "Export JSON",
"exportNote": "An export holds the whole filter, up to {{cap}} rows.",
"exportTruncated": "{{total}} rows match; the export holds the first {{cap}}.",
"rows": "{{from}}–{{to}} of {{total}}",
"prev": "Previous page",
"next": "Next page",
"empty": "Nothing matches these filters.",
"reset": "Reset filters"
},
"tokensExCacheReads": "Tokens (excl. cache reads)", "tokensExCacheReads": "Tokens (excl. cache reads)",
"cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate", "cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
"avgPerWeekday30d": "Average per weekday, last 30 days" "avgPerWeekday30d": "Average per weekday, last 30 days"
+5 -1
View File
@@ -68,7 +68,11 @@
"justNow": "just now", "justNow": "just now",
"mAgo": "{{count}}m ago", "mAgo": "{{count}}m ago",
"hAgo": "{{count}}h ago", "hAgo": "{{count}}h ago",
"dAgo": "{{count}}d ago" "dAgo": "{{count}}d ago",
"justNowShort": "now",
"mAgoShort": "{{count}}m",
"hAgoShort": "{{count}}h",
"dAgoShort": "{{count}}d"
}, },
"pagination": { "pagination": {
"previous": "Previous", "previous": "Previous",
+7 -3
View File
@@ -60,6 +60,9 @@
"changes.commitPlaceholder": "Commit message", "changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged", "changes.commitStaged": "Commit {{count}} staged",
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)", "changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
"changes.neverStage": "Never stage — Stage all skips this file",
"changes.allowStage": "Include in Stage all again",
"changes.keptHint": "Never staged by Stage all (this browser)",
"changes.conflicted": "conflict", "changes.conflicted": "conflict",
"changes.discard": "Discard this file's changes", "changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone", "changes.discardConfirm": "Click again to discard — this cannot be undone",
@@ -113,6 +116,7 @@
"changes.truncated": "Some changes were left out to keep this panel responsive.", "changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This workspace's directory is not a readable git repository.", "changes.unavailable": "This workspace's directory is not a readable git repository.",
"changes.unstage": "Unstage this file", "changes.unstage": "Unstage this file",
"changes.changesView": "Changes",
"changes.wholeFile": "Whole file", "changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel", "confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove", "confirmRemoveConfirm": "Remove",
@@ -211,8 +215,8 @@
"proof.ticketReport": "Task report", "proof.ticketReport": "Task report",
"renameLabel": "Rename this workspace", "renameLabel": "Rename this workspace",
"reorderHint": "Drag to reorder · Alt+←/→", "reorderHint": "Drag to reorder · Alt+←/→",
"router.native": "Native ({{tool}})", "proxy.native": "Native ({{tool}})",
"router.label": "AI Router", "proxy.label": "AI Proxy",
"runtime.boot": "▶ up", "runtime.boot": "▶ up",
"runtime.busy.down": "stopping…", "runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…", "runtime.busy.up": "booting…",
@@ -239,8 +243,8 @@
"changes.undoHunkShortConfirm": "Confirm undo", "changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit", "changes.edit": "Edit",
"changes.save": "Save", "changes.save": "Save",
"changes.revertBlock": "Revert",
"changes.cancelEdit": "Cancel", "changes.cancelEdit": "Cancel",
"changes.clickToEdit": "Click the text to edit",
"changes.preview": "Preview", "changes.preview": "Preview",
"changes.raw": "Raw", "changes.raw": "Raw",
"changes.editorLabel": "Edit {{path}}", "changes.editorLabel": "Edit {{path}}",
+21 -1
View File
@@ -1,6 +1,7 @@
{ {
"activityFeed": "Activity Feed", "activityFeed": "Activity Feed",
"agentBoard": "Kanban Board", "agentBoard": "Kanban Board",
"agents": "Agents",
"analytics": "Analytics", "analytics": "Analytics",
"avg": "avg", "avg": "avg",
"brand": "AgentHub", "brand": "AgentHub",
@@ -67,10 +68,29 @@
"today": "Today {{cost}}", "today": "Today {{cost}}",
"workspace": "Workspace {{cost}}", "workspace": "Workspace {{cost}}",
"session": "Session {{cost}}", "session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)" "budget": "Budget {{pct}}%",
"budgetTitle": "{{name}}: {{spend}} of {{limit}} this period",
"tokens": "({{tokens}} Tokens)",
"quotaUsed": "{{pct}}% used",
"quotaTitle": "Usage",
"quotaResets": "Resets in {{time}}",
"quotaAsOf": "as of {{ago}}",
"quotaRefresh": "Refresh",
"quotaRefreshing": "Refreshing…",
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
"quotaFailed": "Could not read the quota",
"quotaActive": "in use",
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
"quotaSignInExpires": "Sign-in expires in {{time}}",
"quotaSignInExpired": "Sign-in expired",
"quotaWindow": {
"wk": "Week",
"mo": "Month"
}
}, },
"version": "Version", "version": "Version",
"openMenu": "Open menu", "openMenu": "Open menu",
"resizeSidebar": "Resize sidebar (double-click to reset)",
"closeMenu": "Close menu", "closeMenu": "Close menu",
"sidebar": "Sidebar", "sidebar": "Sidebar",
"now": "now" "now": "now"
+49
View File
@@ -54,6 +54,55 @@
"copyId": "Copy session ID", "copyId": "Copy session ID",
"copyFolder": "Copy folder path" "copyFolder": "Copy folder path"
}, },
"replay": {
"tab": "Replay",
"loading": "Loading events…",
"label": "Session replay",
"truncated": "Replaying the newest {{shown}} of {{total}} events.",
"start": "Jump to start (Home)",
"back": "Step back (←)",
"play": "Play (Space)",
"pause": "Pause (Space)",
"forward": "Step forward (→)",
"end": "Jump to end (End)",
"speed": "Playback speed",
"position": "{{at}} / {{n}}",
"scrubber": "Event position",
"minimap": "Lifecycle markers",
"marker": {
"SessionStart": "Session start",
"UserPromptSubmit": "Prompt",
"Compaction": "Compaction",
"SubagentStop": "Subagent done",
"APIError": "API error",
"Stop": "Turn end"
},
"state": {
"status": "Status",
"tool": "Running tool",
"prompts": "Prompts",
"errors": "Errors",
"compactions": "Compactions",
"elapsed": "Elapsed",
"toolCounts": "Tool calls so far"
}
},
"snapshot": {
"create": "Snapshot",
"hint": "Freeze this session into a read-only copy with a share link",
"title": "Create a snapshot",
"body": "Freezes this session as it is now — its agents, events, stats and cost — into a read-only copy under a private link. Later activity does not change it, and it survives Clear Data.",
"redactPaths": "Redact file paths (working directory, transcript and file paths)",
"confirm": "Create and copy link",
"cancel": "Cancel",
"open": "Open snapshot",
"notFound": "Snapshot not found",
"banner": "Snapshot taken {{date}}",
"bannerBody": "A read-only copy of the session as it was then. Nothing here updates.",
"redacted": "Paths redacted",
"openLive": "Open live session",
"timeline": "Timeline ({{n}})"
},
"exportCsv": "CSV", "exportCsv": "CSV",
"exportJson": "JSON", "exportJson": "JSON",
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.", "exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
+113 -24
View File
@@ -21,7 +21,7 @@
"import": "Import .codex-pet bundle…", "import": "Import .codex-pet bundle…",
"browse": "Find more on codex-pets.net", "browse": "Find more on codex-pets.net",
"settings": "Pet settings…", "settings": "Pet settings…",
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.", "hiddenToast": "Pet hidden. Show it again in Settings → Appearance.",
"deleteTitle": "Delete {{name}}?", "deleteTitle": "Delete {{name}}?",
"deleteMessage": "The pet's files are removed from this install. A featured pet can be downloaded again; an upload is gone for good.", "deleteMessage": "The pet's files are removed from this install. A featured pet can be downloaded again; an upload is gone for good.",
"deleteConfirm": "Delete pet", "deleteConfirm": "Delete pet",
@@ -303,6 +303,14 @@
"typeToConfirm": "Type “{{phrase}}” to confirm:", "typeToConfirm": "Type “{{phrase}}” to confirm:",
"phrase": "clear all data" "phrase": "clear all data"
}, },
"autostart": {
"title": "Startup",
"label": "Start AgentHub automatically",
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
},
"about": { "about": {
"title": "About", "title": "About",
"description": "Server runtime information.", "description": "Server runtime information.",
@@ -561,16 +569,21 @@
"deleteAndPurge": "Remove + delete data", "deleteAndPurge": "Remove + delete data",
"removeKeep": "Remove (keep data)", "removeKeep": "Remove (keep data)",
"remoteSourceBadgeTitle": "Collected from a remote machine", "remoteSourceBadgeTitle": "Collected from a remote machine",
"fieldSyncCodex": "Also pull Codex sessions",
"fieldRemoteCodexHome": "Remote Codex home (optional)",
"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.",
"codexOn": "+ Codex",
"syncCodex": "{{n}} Codex",
"fieldLabelPlaceholder": "Dev box" "fieldLabelPlaceholder": "Dev box"
}, },
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).", "cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
"aiRouter": { "aiProxy": {
"title": "AI Router", "title": "AI Proxy",
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.", "description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
"add": "Add profile", "add": "Add profile",
"edit": "Edit", "edit": "Edit",
"remove": "Remove", "remove": "Remove",
"empty": "No router profile saved yet. Native runs against the vendor's own API.", "empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.", "builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
"colLabel": "Profile", "colLabel": "Profile",
"colBaseUrl": "Endpoint", "colBaseUrl": "Endpoint",
@@ -579,16 +592,16 @@
"keySet": "Saved", "keySet": "Saved",
"keyMissing": "None", "keyMissing": "None",
"fieldLabel": "Name", "fieldLabel": "Name",
"fieldLabelHint": "What you will see in the workspace form. Free text.", "fieldLabelHint": "What you will see in the session's AI Proxy picker. Free text.",
"fieldBaseUrl": "Base URL", "fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1", "fieldBaseUrlHint": "The proxy's root URL, without /v1 — e.g. http://localhost:20128. Claude Code adds /v1/messages and Codex /v1/responses; a /v1 you type is dropped.",
"fieldApiKey": "API key", "fieldApiKey": "API key",
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.", "fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
"fieldApiKeyKeep": "Leave blank to keep the saved key.", "fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model", "fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.", "fieldModelHint": "Used for every tier unless you override one below. Many proxies accept one symbolic name.",
"fieldBehavesAs": "Behaves as (Claude Code)", "fieldBehavesAs": "Behaves as (Claude Code)",
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.", "fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this proxy's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the proxy really sends to. While set, it replaces your own modelPicker setting in these sessions.",
"perTier": "Per-tier overrides", "perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.", "perTierHint": "Leave a field blank to use the model above.",
"tier": { "tier": {
@@ -600,12 +613,12 @@
}, },
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.", "removeConfirm": "Remove the profile \"{{label}}\"? Its session files go with it, and a session that ran on it resumes on the vendor's own API.",
"loading": "Loading profiles…", "loading": "Loading profiles…",
"test": "Test", "test": "Test",
"testing": "Testing…", "testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.", "testOk": "answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}" "testFailed": "no answer ({{status}}): {{message}}"
}, },
"aiTools": { "aiTools": {
"title": "AI Tools", "title": "AI Tools",
@@ -653,7 +666,10 @@
"reset": "Reset to defaults", "reset": "Reset to defaults",
"resetTitle": "Reset appearance?", "resetTitle": "Reset appearance?",
"resetBody": "Mode goes back to Dark and both modes to their default theme.", "resetBody": "Mode goes back to Dark and both modes to their default theme.",
"resetDone": "Appearance reset." "resetDone": "Appearance reset.",
"theme": "Theme",
"pet": "Pet",
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
}, },
"gitCreds": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
@@ -684,7 +700,7 @@
}, },
"codexHome": { "codexHome": {
"title": "CODEX_HOME", "title": "CODEX_HOME",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.", "description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Proxy layers into it, and starts every codex workspace with it.",
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.", "restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
"placeholder": "/path/to/codex-home" "placeholder": "/path/to/codex-home"
}, },
@@ -753,17 +769,9 @@
"cost": "Cost" "cost": "Cost"
}, },
"section": { "section": {
"general": {
"title": "General",
"description": "The dashboard's display language."
},
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only." "description": "Language, colour mode and theme, and the corner pet."
},
"pet": {
"title": "Pet",
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
}, },
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",
@@ -784,8 +792,8 @@
"tool": { "tool": {
"description": "{{tool}}: path, usage, default model and config files." "description": "{{tool}}: path, usage, default model and config files."
}, },
"aiRouter": { "aiProxy": {
"title": "AI Router", "title": "AI Proxy",
"description": "Endpoints that stand in front of Claude Code and Codex." "description": "Endpoints that stand in front of Claude Code and Codex."
}, },
"pricing": { "pricing": {
@@ -807,6 +815,14 @@
"about": { "about": {
"title": "About", "title": "About",
"description": "Server version, uptime and runtime." "description": "Server version, uptime and runtime."
},
"snapshots": {
"title": "Snapshots",
"description": "Frozen, read-only copies of sessions, each behind its own link."
},
"reports": {
"title": "Reports",
"description": "Analytics summaries written on a schedule, as HTML or JSON."
} }
}, },
"backPrevious": "Back", "backPrevious": "Back",
@@ -844,6 +860,79 @@
"enable": "Enable", "enable": "Enable",
"disable": "Disable" "disable": "Disable"
}, },
"snapshots": {
"title": "Session snapshots",
"description": "Created from a session's page with Snapshot. They survive Clear Data; delete them here.",
"empty": "No snapshots yet.",
"events": "{{n}} events",
"redacted": "Paths redacted",
"open": "Open",
"copyLink": "Copy link",
"delete": "Delete",
"deleteTitle": "Delete this snapshot?",
"deleteBody": "Its link stops working. The session itself is not touched.",
"cancel": "Cancel"
},
"pushIngest": {
"title": "Push ingest",
"description": "For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:<name>) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.",
"on": "On — a token exists",
"off": "Off — no token yet",
"create": "Create token",
"rotate": "Rotate token",
"revoke": "Revoke",
"rotateConfirm": "Make a new token? The current one stops working at once.",
"revokeConfirm": "Revoke the token and turn push ingest off?",
"shownOnce": "Copy this token now: only its hash is stored, so it is never shown again.",
"copy": "Copy",
"envOwned": "AGENTHUB_PUSH_TOKEN_SHA256 is set by the environment the dashboard started in; change it there.",
"reachHint": "The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:",
"limits": "At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not."
},
"privacy": {
"title": "Privacy",
"description": "What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.",
"redact": "Redact secrets before storing",
"redactHint": "API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.",
"ignore": "Ignored folders",
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
},
"reports": {
"title": "Scheduled reports",
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
"add": "New report",
"defaultName": "Weekly summary",
"empty": "No scheduled report yet.",
"name": "Name",
"windowDays": "Days covered",
"frequency": "Frequency",
"daily": "Daily",
"weekly": "Weekly",
"weekday": "Day",
"hour": "Time",
"windowHint": "A report covers the full days before the day it runs, at this browser's time zone.",
"save": "Save",
"cancel": "Cancel",
"enabled": "Enabled",
"runNow": "Run now",
"history": "History",
"edit": "Edit",
"delete": "Delete",
"everyDay": "Every day at {{time}}",
"everyWeek": "Every {{day}} at {{time}}",
"window_one": "last {{count}} day",
"window_other": "last {{count}} days",
"lastRun": "Last run {{at}}",
"noRuns": "No runs yet.",
"trigger": {
"schedule": "Scheduled",
"manual": "Manual"
},
"download": "Download {{format}}",
"deleteTitle": "Delete this report?",
"deleteBody": "Its schedule, its run history and the files it wrote are deleted."
},
"prefsBackup": { "prefsBackup": {
"title": "This browser's preferences", "title": "This browser's preferences",
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.", "description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
+1
View File
@@ -25,6 +25,7 @@
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.", "releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.", "pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...", "pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
"pluginAlreadyCurrent": "No newer plugin version was pulled, so the running dashboard was left as it is.",
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.", "lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
"restartingShort": "Restarting…", "restartingShort": "Restarting…",
"installedTitle": "Version {{version}} is installed but not running", "installedTitle": "Version {{version}} is installed but not running",
+33 -5
View File
@@ -7,20 +7,26 @@
"event_pattern": "Mẫu sự kiện", "event_pattern": "Mẫu sự kiện",
"inactivity": "Không hoạt động", "inactivity": "Không hoạt động",
"status_duration": "Agent bị kẹt", "status_duration": "Agent bị kẹt",
"token_threshold": "Ngưỡng token" "token_threshold": "Ngưỡng token",
"cost_threshold": "Ngưỡng chi phí",
"budget": "Ngân sách chi tiêu"
}, },
"ruleTypeHints": { "ruleTypeHints": {
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).", "event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
"inactivity": "Kích hoạt khi một phiên đang hoạt động không nhận được sự kiện nào trong số phút đã cấu hình.", "inactivity": "Kích hoạt khi một phiên đang hoạt động không nhận được sự kiện nào trong số phút đã cấu hình.",
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.", "status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng." "token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
}, },
"ruleDesc": { "ruleDesc": {
"eventPattern": "Khớp {{pattern}}", "eventPattern": "Khớp {{pattern}}",
"eventPatternCount": "≥{{count}} × {{pattern}} trong {{window}} phút", "eventPatternCount": "≥{{count}} × {{pattern}} trong {{window}} phút",
"inactivity": "Phiên không hoạt động trong {{minutes}} phút", "inactivity": "Phiên không hoạt động trong {{minutes}} phút",
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút", "statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
"tokenThreshold": "Token của phiên vượt {{tokens}}" "tokenThreshold": "Token của phiên vượt {{tokens}}",
"costThreshold": "Chi phí phiên chạm {{usd}}",
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
}, },
"rules": { "rules": {
"title": "Quy tắc cảnh báo", "title": "Quy tắc cảnh báo",
@@ -48,7 +54,13 @@
"agentStatus": "Trạng thái agent", "agentStatus": "Trạng thái agent",
"minutes": "Phút", "minutes": "Phút",
"totalTokens": "Tổng token", "totalTokens": "Tổng token",
"cooldown": "Thời gian chờ (giây)" "cooldown": "Thời gian chờ (giây)",
"usd": "Chi phí (USD)",
"period": "Kỳ",
"limitUsd": "Hạn mức (USD)",
"thresholds": "Cảnh báo ở (%)",
"budgetTool": "Công cụ AI",
"allTools": "Mọi công cụ AI"
}, },
"deleteTitle": "Xóa quy tắc cảnh báo?", "deleteTitle": "Xóa quy tắc cảnh báo?",
"help": { "help": {
@@ -63,8 +75,19 @@
"minutesInactivity": "Kích hoạt khi một phiên đang hoạt động không có sự kiện nào trong bấy nhiêu phút (phiên bị treo hoặc bị quên).", "minutesInactivity": "Kích hoạt khi một phiên đang hoạt động không có sự kiện nào trong bấy nhiêu phút (phiên bị treo hoặc bị quên).",
"minutesStatus": "Kích hoạt khi agent ở trạng thái đã chọn mà không có hoạt động trong bấy nhiêu phút (agent bị kẹt).", "minutesStatus": "Kích hoạt khi agent ở trạng thái đã chọn mà không có hoạt động trong bấy nhiêu phút (agent bị kẹt).",
"totalTokens": "Kích hoạt khi tổng token của phiên (input + output + cache read + cache write) vượt số này. vd 1000000 = 1 triệu token.", "totalTokens": "Kích hoạt khi tổng token của phiên (input + output + cache read + cache write) vượt số này. vd 1000000 = 1 triệu token.",
"cooldown": "Số giây tối thiểu giữa các cảnh báo lặp cho cùng quy tắc + phiên + agent. Tránh trùng lặp gây nhiễu. Mặc định 300 (5 phút); 0 = không có thời gian chờ." "cooldown": "Số giây tối thiểu giữa các cảnh báo lặp cho cùng quy tắc + phiên + agent. Tránh trùng lặp gây nhiễu. Mặc định 300 (5 phút); 0 = không có thời gian chờ.",
"usd": "Một phiên chạm số đô la này sẽ kích hoạt cảnh báo.",
"period": "Ngày bắt đầu lúc nửa đêm giờ địa phương, tuần từ thứ Hai, tháng từ ngày 1.",
"limitUsd": "Hạn mức chi tiêu cho một kỳ, tính bằng đô la Mỹ.",
"thresholds": "Phần trăm hạn mức, cách nhau bằng dấu phẩy, vd 50, 80, 100. Mỗi ngưỡng kích hoạt một lần mỗi kỳ.",
"budgetTool": "Chỉ tính các phiên của công cụ AI này."
}, },
"preview": "Xem trước",
"previewing": "Đang kiểm tra…",
"previewHint": "Quy tắc này lẽ ra đã kích hoạt những gì trong 7 ngày qua — không lưu, không gửi gì",
"previewResult": "Lẽ ra đã kích hoạt {{fires}} lần ở {{sessions}} phiên trong {{days}} ngày qua.",
"previewBudget": "Chi tiêu mỗi kỳ (kỳ này và năm kỳ trước):",
"previewUnsupported": "Loại quy tắc này không phát lại được từ lịch sử.",
"edit": "Sửa quy tắc", "edit": "Sửa quy tắc",
"save": "Lưu quy tắc", "save": "Lưu quy tắc",
"typeFixed": "Không đổi được loại của quy tắc — hãy tạo quy tắc mới.", "typeFixed": "Không đổi được loại của quy tắc — hãy tạo quy tắc mới.",
@@ -81,6 +104,11 @@
"emptyDescription": "Cảnh báo do các quy tắc của bạn kích hoạt sẽ hiển thị tại đây theo thời gian thực.", "emptyDescription": "Cảnh báo do các quy tắc của bạn kích hoạt sẽ hiển thị tại đây theo thời gian thực.",
"emptyUnacked": "Tất cả đã được xác nhận. Cảnh báo mới sẽ hiển thị tại đây khi được kích hoạt." "emptyUnacked": "Tất cả đã được xác nhận. Cảnh báo mới sẽ hiển thị tại đây khi được kích hoạt."
}, },
"periods": {
"day": "ngày",
"week": "tuần",
"month": "tháng"
},
"errors": { "errors": {
"loadRules": "Không tải được quy tắc cảnh báo: {{error}}", "loadRules": "Không tải được quy tắc cảnh báo: {{error}}",
"loadAlerts": "Không tải được cảnh báo: {{error}}", "loadAlerts": "Không tải được cảnh báo: {{error}}",
+50
View File
@@ -51,6 +51,56 @@
"last7Days": "7 ngày gần nhất", "last7Days": "7 ngày gần nhất",
"less": "Ít", "less": "Ít",
"more": "Nhiều", "more": "Nhiều",
"toolFilter": "Công cụ AI",
"allTools": "Mọi công cụ AI",
"costByTool": "Chi phí theo công cụ AI",
"spendPace": "Nhịp chi tiêu",
"burnRate": "Tốc độ đốt (TB 7 ngày / ngày)",
"burnRateHint": "Chi phí trung bình mỗi ngày trong 7 ngày qua, tính cả hôm nay",
"monthToDate": "Từ đầu tháng",
"forecastMonth": "Dự báo cả tháng",
"forecastHint": "Từ đầu tháng cộng nhịp 7 ngày cho mỗi ngày còn lại",
"budgets": "Ngân sách",
"noBudgets": "Chưa có ngân sách chi tiêu.",
"addBudget": "Thêm trong Cài đặt → Thông báo",
"projected": "dự kiến",
"projectedHint": "Chi tiêu kỳ này theo nhịp hiện tại, kéo đến cuối kỳ",
"range": "Khoảng thời gian",
"rangePreset": {
"today": "Hôm nay",
"week": "Tuần này",
"month": "Tháng này",
"7d": "7 ngày qua",
"30d": "30 ngày qua",
"90d": "90 ngày qua",
"all": "Toàn bộ",
"custom": "Tùy chọn"
},
"rangeFrom": "Từ ngày",
"rangeTo": "Đến ngày",
"query": {
"entities": {
"agents": "Agent"
},
"search": "Tìm kiếm",
"searchPlaceholder": "Tìm văn bản…",
"status": "Trạng thái",
"allStatuses": "Mọi trạng thái",
"cwd": "Thư mục",
"cwdPlaceholder": "/duong/dan/du-an",
"from": "Từ ngày",
"to": "Đến ngày",
"sortBy": "Sắp xếp theo {{column}}",
"exportCsv": "Xuất CSV",
"exportJson": "Xuất JSON",
"exportNote": "Tệp xuất chứa toàn bộ kết quả lọc, tối đa {{cap}} dòng.",
"exportTruncated": "{{total}} dòng khớp; tệp xuất chỉ chứa {{cap}} dòng đầu.",
"rows": "{{from}}–{{to}} / {{total}}",
"prev": "Trang trước",
"next": "Trang sau",
"empty": "Không có dữ liệu khớp bộ lọc.",
"reset": "Đặt lại bộ lọc"
},
"tokensExCacheReads": "Token (trừ đọc cache)", "tokensExCacheReads": "Token (trừ đọc cache)",
"cacheReadsSub": "+{{reads}} token đọc cache · tỉ lệ trúng {{pct}}%", "cacheReadsSub": "+{{reads}} token đọc cache · tỉ lệ trúng {{pct}}%",
"avgPerWeekday30d": "Trung bình theo thứ trong tuần, 30 ngày qua" "avgPerWeekday30d": "Trung bình theo thứ trong tuần, 30 ngày qua"
+5 -1
View File
@@ -68,7 +68,11 @@
"justNow": "vừa xong", "justNow": "vừa xong",
"mAgo": "{{count}} phút trước", "mAgo": "{{count}} phút trước",
"hAgo": "{{count}} giờ trước", "hAgo": "{{count}} giờ trước",
"dAgo": "{{count}} ngày trước" "dAgo": "{{count}} ngày trước",
"justNowShort": "vừa xong",
"mAgoShort": "{{count}} phút",
"hAgoShort": "{{count}} giờ",
"dAgoShort": "{{count}} ngày"
}, },
"pagination": { "pagination": {
"previous": "Trước", "previous": "Trước",
+7 -3
View File
@@ -60,6 +60,9 @@
"changes.commitPlaceholder": "Nội dung commit", "changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage", "changes.commitStaged": "Commit {{count}} file đã stage",
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)", "changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
"changes.neverStage": "Không bao giờ stage — Stage all bỏ qua file này",
"changes.allowStage": "Cho Stage all stage lại file này",
"changes.keptHint": "Stage all không bao giờ stage file này (trên trình duyệt này)",
"changes.conflicted": "xung đột", "changes.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này", "changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác", "changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
@@ -113,6 +116,7 @@
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.", "changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.", "changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index", "changes.unstage": "Bỏ file này khỏi index",
"changes.changesView": "Thay đổi",
"changes.wholeFile": "Toàn bộ file", "changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy", "confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa", "confirmRemoveConfirm": "Xóa",
@@ -211,8 +215,8 @@
"proof.ticketReport": "Báo cáo nhiệm vụ", "proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên workspace này", "renameLabel": "Đổi tên workspace này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→", "reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.native": "Native ({{tool}})", "proxy.native": "Native ({{tool}})",
"router.label": "AI Router", "proxy.label": "AI Proxy",
"runtime.boot": "▶ chạy", "runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…", "runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…", "runtime.busy.up": "đang khởi động…",
@@ -239,8 +243,8 @@
"changes.undoHunkShortConfirm": "Xác nhận", "changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa", "changes.edit": "Sửa",
"changes.save": "Lưu", "changes.save": "Lưu",
"changes.revertBlock": "Hoàn tác",
"changes.cancelEdit": "Huỷ", "changes.cancelEdit": "Huỷ",
"changes.clickToEdit": "Bấm vào nội dung để sửa",
"changes.preview": "Xem trước", "changes.preview": "Xem trước",
"changes.raw": "Mã nguồn", "changes.raw": "Mã nguồn",
"changes.editorLabel": "Sửa {{path}}", "changes.editorLabel": "Sửa {{path}}",
+21 -1
View File
@@ -1,6 +1,7 @@
{ {
"activityFeed": "Luồng hoạt động", "activityFeed": "Luồng hoạt động",
"agentBoard": "Bảng Kanban", "agentBoard": "Bảng Kanban",
"agents": "Agent",
"analytics": "Phân tích", "analytics": "Phân tích",
"avg": "TB", "avg": "TB",
"brand": "AgentHub", "brand": "AgentHub",
@@ -67,10 +68,29 @@
"today": "Hôm nay {{cost}}", "today": "Hôm nay {{cost}}",
"workspace": "Workspace {{cost}}", "workspace": "Workspace {{cost}}",
"session": "Session {{cost}}", "session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)" "budget": "Ngân sách {{pct}}%",
"budgetTitle": "{{name}}: {{spend}} / {{limit}} kỳ này",
"tokens": "({{tokens}} Tokens)",
"quotaUsed": "đã dùng {{pct}}%",
"quotaTitle": "Mức dùng",
"quotaResets": "Đặt lại sau {{time}}",
"quotaAsOf": "lúc {{ago}}",
"quotaRefresh": "Làm mới",
"quotaRefreshing": "Đang làm mới…",
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
"quotaFailed": "Không đọc được quota",
"quotaActive": "đang dùng",
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
"quotaSignInExpired": "Đăng nhập đã hết hạn",
"quotaWindow": {
"wk": "Tuần",
"mo": "Tháng"
}
}, },
"version": "Phiên bản", "version": "Phiên bản",
"openMenu": "Mở menu", "openMenu": "Mở menu",
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
"closeMenu": "Đóng menu", "closeMenu": "Đóng menu",
"sidebar": "Thanh bên", "sidebar": "Thanh bên",
"now": "bây giờ" "now": "bây giờ"
+49
View File
@@ -53,6 +53,55 @@
"copyId": "Sao chép ID session", "copyId": "Sao chép ID session",
"copyFolder": "Sao chép đường dẫn thư mục" "copyFolder": "Sao chép đường dẫn thư mục"
}, },
"replay": {
"tab": "Phát lại",
"loading": "Đang tải sự kiện…",
"label": "Phát lại phiên",
"truncated": "Đang phát lại {{shown}} sự kiện mới nhất trong {{total}}.",
"start": "Về đầu (Home)",
"back": "Lùi một bước (←)",
"play": "Phát (Space)",
"pause": "Tạm dừng (Space)",
"forward": "Tiến một bước (→)",
"end": "Đến cuối (End)",
"speed": "Tốc độ phát",
"position": "{{at}} / {{n}}",
"scrubber": "Vị trí sự kiện",
"minimap": "Mốc vòng đời",
"marker": {
"SessionStart": "Bắt đầu phiên",
"UserPromptSubmit": "Prompt",
"Compaction": "Nén ngữ cảnh",
"SubagentStop": "Subagent xong",
"APIError": "Lỗi API",
"Stop": "Kết thúc lượt"
},
"state": {
"status": "Trạng thái",
"tool": "Công cụ đang chạy",
"prompts": "Prompt",
"errors": "Lỗi",
"compactions": "Lần nén",
"elapsed": "Đã trôi qua",
"toolCounts": "Số lần gọi công cụ đến lúc này"
}
},
"snapshot": {
"create": "Snapshot",
"hint": "Đóng băng phiên này thành một bản chỉ đọc kèm liên kết chia sẻ",
"title": "Tạo snapshot",
"body": "Đóng băng phiên như hiện tại — agent, sự kiện, thống kê và chi phí — thành một bản chỉ đọc dưới một liên kết riêng. Hoạt động sau đó không làm nó thay đổi, và nó vẫn còn sau khi Xóa dữ liệu.",
"redactPaths": "Che đường dẫn (thư mục làm việc, transcript và đường dẫn tệp)",
"confirm": "Tạo và sao chép liên kết",
"cancel": "Hủy",
"open": "Mở snapshot",
"notFound": "Không tìm thấy snapshot",
"banner": "Snapshot chụp lúc {{date}}",
"bannerBody": "Bản chỉ đọc của phiên tại thời điểm đó. Không có gì ở đây được cập nhật.",
"redacted": "Đã che đường dẫn",
"openLive": "Mở phiên đang có",
"timeline": "Dòng thời gian ({{n}})"
},
"exportCsv": "CSV", "exportCsv": "CSV",
"exportJson": "JSON", "exportJson": "JSON",
"exportCapped": "Tệp chỉ gồm {{shown}} trên {{total}} phiên (giới hạn xuất) — hãy thu hẹp bộ lọc để xuất phần còn lại.", "exportCapped": "Tệp chỉ gồm {{shown}} trên {{total}} phiên (giới hạn xuất) — hãy thu hẹp bộ lọc để xuất phần còn lại.",
+113 -24
View File
@@ -21,7 +21,7 @@
"import": "Nhập bundle .codex-pet…", "import": "Nhập bundle .codex-pet…",
"browse": "Tìm thêm trên codex-pets.net", "browse": "Tìm thêm trên codex-pets.net",
"settings": "Cài đặt pet…", "settings": "Cài đặt pet…",
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Pet.", "hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Giao diện.",
"deleteTitle": "Xoá {{name}}?", "deleteTitle": "Xoá {{name}}?",
"deleteMessage": "Các tệp của pet sẽ bị xoá khỏi bản cài này. Pet nổi bật có thể tải lại; ảnh bạn tải lên sẽ mất hẳn.", "deleteMessage": "Các tệp của pet sẽ bị xoá khỏi bản cài này. Pet nổi bật có thể tải lại; ảnh bạn tải lên sẽ mất hẳn.",
"deleteConfirm": "Xoá pet", "deleteConfirm": "Xoá pet",
@@ -303,6 +303,14 @@
"typeToConfirm": "Gõ “{{phrase}}” để xác nhận:", "typeToConfirm": "Gõ “{{phrase}}” để xác nhận:",
"phrase": "xóa toàn bộ dữ liệu" "phrase": "xóa toàn bộ dữ liệu"
}, },
"autostart": {
"title": "Khởi động",
"label": "Tự động khởi động AgentHub",
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
},
"about": { "about": {
"title": "Giới thiệu", "title": "Giới thiệu",
"description": "Thông tin runtime của máy chủ.", "description": "Thông tin runtime của máy chủ.",
@@ -561,16 +569,21 @@
"deleteAndPurge": "Xóa + xóa dữ liệu", "deleteAndPurge": "Xóa + xóa dữ liệu",
"removeKeep": "Xóa (giữ dữ liệu)", "removeKeep": "Xóa (giữ dữ liệu)",
"remoteSourceBadgeTitle": "Được thu thập từ một máy từ xa", "remoteSourceBadgeTitle": "Được thu thập từ một máy từ xa",
"fieldSyncCodex": "Kéo cả phiên Codex",
"fieldRemoteCodexHome": "Thư mục Codex trên máy từ xa (tùy chọn)",
"fieldRemoteCodexHomeHint": "Mặc định ~/.codex (wsl:~/.codex khi thư mục Claude nằm trong WSL). Thư mục sessions/ của nó được sao chép về, và các phiên hiện ra như phiên Codex của nguồn này, kèm token và chi phí.",
"codexOn": "+ Codex",
"syncCodex": "{{n}} Codex",
"fieldLabelPlaceholder": "Máy dev" "fieldLabelPlaceholder": "Máy dev"
}, },
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).", "cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
"aiRouter": { "aiProxy": {
"title": "AI Router", "title": "AI Proxy",
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.", "description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
"add": "Thêm profile", "add": "Thêm profile",
"edit": "Sửa", "edit": "Sửa",
"remove": "Xoá", "remove": "Xoá",
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.", "empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.", "builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
"colLabel": "Profile", "colLabel": "Profile",
"colBaseUrl": "Endpoint", "colBaseUrl": "Endpoint",
@@ -579,16 +592,16 @@
"keySet": "Đã lưu", "keySet": "Đã lưu",
"keyMissing": "Chưa có", "keyMissing": "Chưa có",
"fieldLabel": "Tên", "fieldLabel": "Tên",
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo workspace. Gõ tự do.", "fieldLabelHint": "Tên bạn sẽ thấy trong ô chọn AI Proxy của session. Gõ tự do.",
"fieldBaseUrl": "Base URL", "fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1", "fieldBaseUrlHint": "URL gốc của proxy, không có /v1 — ví dụ http://localhost:20128. Claude Code tự thêm /v1/messages, Codex tự thêm /v1/responses; /v1 bạn gõ vào sẽ được bỏ đi.",
"fieldApiKey": "API key", "fieldApiKey": "API key",
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.", "fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.", "fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model", "fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.", "fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều proxy chỉ cần một tên tượng trưng.",
"fieldBehavesAs": "Hoạt động như (Claude Code)", "fieldBehavesAs": "Hoạt động như (Claude Code)",
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.", "fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của proxy này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model proxy thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
"perTier": "Ghi đè theo từng bậc", "perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.", "perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": { "tier": {
@@ -600,12 +613,12 @@
}, },
"save": "Lưu", "save": "Lưu",
"cancel": "Huỷ", "cancel": "Huỷ",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi workspace đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.", "removeConfirm": "Xoá profile \"{{label}}\"? Các file session của nó bị xoá theo, và một session từng chạy trên nó sẽ resume bằng API gốc của hãng.",
"loading": "Đang tải danh sách profile…", "loading": "Đang tải danh sách profile…",
"test": "Kiểm tra", "test": "Kiểm tra",
"testing": "Đang kiểm tra…", "testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.", "testOk": "đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}" "testFailed": "không trả lời ({{status}}): {{message}}"
}, },
"aiTools": { "aiTools": {
"title": "AI Tools", "title": "AI Tools",
@@ -653,7 +666,10 @@
"reset": "Khôi phục mặc định", "reset": "Khôi phục mặc định",
"resetTitle": "Khôi phục giao diện?", "resetTitle": "Khôi phục giao diện?",
"resetBody": "Chế độ trở về Tối và cả hai chế độ trở về giao diện mặc định.", "resetBody": "Chế độ trở về Tối và cả hai chế độ trở về giao diện mặc định.",
"resetDone": "Đã khôi phục giao diện." "resetDone": "Đã khôi phục giao diện.",
"theme": "Theme",
"pet": "Pet",
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
}, },
"gitCreds": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
@@ -684,7 +700,7 @@
}, },
"codexHome": { "codexHome": {
"title": "CODEX_HOME", "title": "CODEX_HOME",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi workspace codex với thư mục này.", "description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Proxy vào đây, và mở mọi workspace codex với thư mục này.",
"restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.", "restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
"placeholder": "/đường-dẫn/tới/codex-home" "placeholder": "/đường-dẫn/tới/codex-home"
}, },
@@ -753,17 +769,9 @@
"cost": "Chi phí" "cost": "Chi phí"
}, },
"section": { "section": {
"general": {
"title": "Chung",
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
},
"appearance": { "appearance": {
"title": "Giao diện", "title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này." "description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
},
"pet": {
"title": "Pet",
"description": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
}, },
"notifications": { "notifications": {
"title": "Thông báo", "title": "Thông báo",
@@ -784,8 +792,8 @@
"tool": { "tool": {
"description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình." "description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình."
}, },
"aiRouter": { "aiProxy": {
"title": "AI Router", "title": "AI Proxy",
"description": "Các endpoint đứng trước Claude Code và Codex." "description": "Các endpoint đứng trước Claude Code và Codex."
}, },
"pricing": { "pricing": {
@@ -807,6 +815,14 @@
"about": { "about": {
"title": "Giới thiệu", "title": "Giới thiệu",
"description": "Phiên bản máy chủ, thời gian hoạt động và môi trường chạy." "description": "Phiên bản máy chủ, thời gian hoạt động và môi trường chạy."
},
"snapshots": {
"title": "Snapshot",
"description": "Các bản sao chỉ đọc, đã đóng băng của phiên, mỗi bản có liên kết riêng."
},
"reports": {
"title": "Báo cáo",
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
} }
}, },
"backPrevious": "Quay lại", "backPrevious": "Quay lại",
@@ -844,6 +860,79 @@
"enable": "Bật", "enable": "Bật",
"disable": "Tắt" "disable": "Tắt"
}, },
"snapshots": {
"title": "Snapshot phiên",
"description": "Tạo từ trang của phiên bằng nút Snapshot. Chúng vẫn còn sau khi Xóa dữ liệu; xóa chúng ở đây.",
"empty": "Chưa có snapshot nào.",
"events": "{{n}} sự kiện",
"redacted": "Đã che đường dẫn",
"open": "Mở",
"copyLink": "Sao chép liên kết",
"delete": "Xóa",
"deleteTitle": "Xóa snapshot này?",
"deleteBody": "Liên kết của nó sẽ không dùng được nữa. Bản thân phiên không bị ảnh hưởng.",
"cancel": "Hủy"
},
"pushIngest": {
"title": "Đẩy dữ liệu về (push ingest)",
"description": "Cho máy mà dashboard không SSH tới được: máy đó tự đẩy hook của nó tới POST /api/hooks/ingest-batch bằng token tạo ở đây. Các phiên được gắn tên máy gửi lên (push:<tên>) và có trạng thái, sự kiện trực tiếp; chi phí token cần transcript, thứ chỉ nguồn SSH mang về.",
"on": "Đang bật — đã có token",
"off": "Đang tắt — chưa có token",
"create": "Tạo token",
"rotate": "Đổi token",
"revoke": "Thu hồi",
"rotateConfirm": "Tạo token mới? Token hiện tại ngừng hoạt động ngay.",
"revokeConfirm": "Thu hồi token và tắt push ingest?",
"shownOnce": "Hãy sao chép token này ngay: chỉ bản băm của nó được lưu, nên nó không bao giờ hiện lại.",
"copy": "Sao chép",
"envOwned": "AGENTHUB_PUSH_TOKEN_SHA256 được đặt bởi môi trường khởi động dashboard; hãy đổi nó ở đó.",
"reachHint": "Máy kia phải truy cập được dashboard này: khởi động với DASHBOARD_HOST (và DASHBOARD_ALLOWED_HOSTS cho tên của nó). Qua http thường, token đi trên mạng ở dạng đọc được — chỉ dùng trong mạng tin cậy, hoặc sau TLS. Script chuyển tiếp dưới đây giữ token trong một tệp header, không bao giờ trên dòng lệnh:",
"limits": "Tối đa 500 sự kiện và 1 MB mỗi yêu cầu. Hook cho phiên thuộc nguồn khác bị từ chối; đường dẫn transcript bị bỏ (chúng trỏ tới tệp trên máy gửi). Nhận hook Codex; không nhận hook Antigravity."
},
"privacy": {
"title": "Quyền riêng tư",
"description": "Những gì AgentHub giữ lại từ hook và lịch sử nhập vào. Cả hai mặc định tắt và chỉ áp dụng cho dữ liệu mới.",
"redact": "Che bí mật trước khi lưu",
"redactHint": "Khóa API và token có dạng đã biết, header bearer, khóa riêng và giá trị password=/TOKEN= trong payload và tóm tắt sự kiện được thay bằng [redacted]. Transcript trên đĩa là tệp của chính CLI và không bị thay đổi.",
"ignore": "Thư mục bỏ qua",
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
},
"reports": {
"title": "Báo cáo định kỳ",
"description": "Bản tóm tắt những ngày gần nhất — phiên, sự kiện, token, chi phí theo model và theo công cụ AI, công cụ dùng nhiều nhất, ngân sách — ghi ra HTML hoặc JSON theo lịch. Báo cáo và lịch sử chạy được giữ lại khi Xóa dữ liệu.",
"add": "Báo cáo mới",
"defaultName": "Tóm tắt hằng tuần",
"empty": "Chưa có báo cáo định kỳ nào.",
"name": "Tên",
"windowDays": "Số ngày",
"frequency": "Tần suất",
"daily": "Hằng ngày",
"weekly": "Hằng tuần",
"weekday": "Ngày",
"hour": "Giờ",
"windowHint": "Báo cáo gồm các ngày trọn vẹn trước ngày chạy, theo múi giờ của trình duyệt này.",
"save": "Lưu",
"cancel": "Hủy",
"enabled": "Bật",
"runNow": "Chạy ngay",
"history": "Lịch sử",
"edit": "Sửa",
"delete": "Xóa",
"everyDay": "Mỗi ngày lúc {{time}}",
"everyWeek": "Mỗi {{day}} lúc {{time}}",
"window_one": "{{count}} ngày gần nhất",
"window_other": "{{count}} ngày gần nhất",
"lastRun": "Lần chạy gần nhất {{at}}",
"noRuns": "Chưa chạy lần nào.",
"trigger": {
"schedule": "Theo lịch",
"manual": "Thủ công"
},
"download": "Tải {{format}}",
"deleteTitle": "Xóa báo cáo này?",
"deleteBody": "Lịch chạy, lịch sử chạy và các tệp nó đã ghi sẽ bị xóa."
},
"prefsBackup": { "prefsBackup": {
"title": "Tùy chọn của trình duyệt này", "title": "Tùy chọn của trình duyệt này",
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.", "description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
+1
View File
@@ -25,6 +25,7 @@
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.", "releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.", "pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...", "pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
"pluginAlreadyCurrent": "Không có phiên bản plugin mới nào được tải về, nên dashboard đang chạy được giữ nguyên.",
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.", "lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
"restartingShort": "Đang khởi động lại…", "restartingShort": "Đang khởi động lại…",
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy", "installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
+6
View File
@@ -105,6 +105,10 @@
--syn-plain: var(--fg-primary); --syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */ --syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */ --syn-del: 185 28 28; /* red-700 #b91c1c */
/* A diff line edited in place (not wholly added or removed). */
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
--kept: 194 65 12; /* orange-700 #c2410c */
} }
.dark { .dark {
@@ -154,6 +158,8 @@
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */ --syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
--syn-add: 110 231 183; /* emerald-300 */ --syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */ --syn-del: 252 165 165; /* red-300 #fca5a5 */
--diff-mod: 96 165 250; /* blue-400 #60a5fa */
--kept: 251 146 60; /* orange-400 #fb923c */
} }
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in /* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
+26
View File
@@ -0,0 +1,26 @@
/**
* @file Tests for spendPace — the 7-day burn rate, month to date and the
* month-end forecast built from the daily cost series.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { spendPace } from "../burnRate";
describe("spendPace", () => {
it("averages the last 7 days and carries the pace to month end", () => {
const daily = [
{ date: "2026-09-28", cost: 70 }, // before the 7-day window (09-29..10-05)
{ date: "2026-09-30", cost: 7 }, // inside the window, last month
{ date: "2026-10-01", cost: 7 },
{ date: "2026-10-05", cost: 14 },
{ date: "2026-10-06", cost: 99 }, // after today: ignored
];
const p = spendPace(daily, "2026-10-05");
expect(p.perDay7).toBe(4); // (7 + 7 + 14) / 7
expect(p.monthToDate).toBe(21);
expect(p.forecastMonth).toBe(21 + 4 * 26); // 26 days left in October
});
it("is all zero with no spend", () => {
expect(spendPace([], "2026-02-28")).toEqual({ perDay7: 0, monthToDate: 0, forecastMonth: 0 });
});
});
@@ -40,3 +40,12 @@ describe("changeTree", () => {
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3); expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
}); });
}); });
describe("changeTree — an untracked folder", () => {
it("shows git's `dir/` entry as a named row in its parent folder", () => {
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
const docs = root.dirs[0]!;
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
});
});
@@ -0,0 +1,48 @@
/**
* @file Tests for the History commit-graph layout: a straight line, a branch
* merged back, converging lanes and a parent beyond the loaded page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { commitGraph } from "../commitGraph";
const c = (sha: string, ...parents: string[]) => ({ sha, parents });
describe("commitGraph", () => {
it("draws a linear history in one column", () => {
const { rows, width } = commitGraph([c("c", "b"), c("b", "a"), c("a")]);
expect(width).toBe(1);
expect(rows.map((r) => r.col)).toEqual([0, 0, 0]);
// The tip has no line from above; the root none below.
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
expect(rows[2]?.segs).toEqual([{ x1: 0, y1: 0, x2: 0, y2: 0.5, color: 0 }]);
});
it("opens a lane for a merged branch and closes it at the fork point", () => {
// m merge of b2 into a2
// |\\
// a2 b2
// | b1
// |/
// a1
const { rows, width } = commitGraph([
c("m", "a2", "b2"),
c("a2", "a1"),
c("b2", "b1"),
c("b1", "a1"),
c("a1"),
]);
expect(width).toBe(2);
expect(rows.map((r) => r.col)).toEqual([0, 0, 1, 1, 0]);
expect(rows[0]?.segs).toContainEqual({ x1: 0, y1: 0.5, x2: 1, y2: 1, color: 1 });
// b1's lane converges into a1's column.
expect(rows[4]?.segs).toContainEqual({ x1: 1, y1: 0, x2: 0, y2: 0.5, color: 1 });
expect(rows[1]?.color).toBe(rows[0]?.color);
expect(rows[2]?.color).not.toBe(rows[0]?.color);
});
it("keeps a lane running for a parent past the page", () => {
const { rows } = commitGraph([c("b", "a")]);
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
});
});
@@ -0,0 +1,47 @@
/**
* @file dateRange.test.ts
* @description The Analytics range presets: `?range=` parsing, a preset's
* local bounds (week from Monday, month from the 1st), a custom range, and the query params `applyRange` sets.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { applyRange, parsePreset, rangeDates } from "../dateRange";
describe("dateRange", () => {
const today = new Date(2026, 9, 5, 12); // local 2026-10-05
it("parses presets, defaulting to all", () => {
expect(parsePreset("30d")).toBe("30d");
expect(parsePreset("1y")).toBe("all");
expect(parsePreset(null)).toBe("all");
});
it("gives a preset's local bounds, today included", () => {
expect(rangeDates("7d", {}, today)).toEqual({ from: "2026-09-29", to: "2026-10-05" });
expect(rangeDates("90d", {}, today).from).toBe("2026-07-08");
expect(rangeDates("all", {}, today)).toEqual({});
});
it("starts the week on Monday and the month on the 1st", () => {
const wed = new Date(2026, 9, 7, 12);
const sun = new Date(2026, 9, 11, 12);
expect(rangeDates("today", {}, wed)).toEqual({ from: "2026-10-07", to: "2026-10-07" });
expect(rangeDates("week", {}, wed)).toEqual({ from: "2026-10-05", to: "2026-10-07" });
expect(rangeDates("week", {}, sun)).toEqual({ from: "2026-10-05", to: "2026-10-11" });
expect(rangeDates("week", {}, today)).toEqual({ from: "2026-10-05", to: "2026-10-05" });
expect(rangeDates("month", {}, wed)).toEqual({ from: "2026-10-01", to: "2026-10-07" });
expect(rangeDates("custom", { from: "2026-01-01", to: "" }, today)).toEqual({
from: "2026-01-01",
to: undefined,
});
});
it("sets from/to and tz_offset only when there is a bound", () => {
expect(applyRange(new URLSearchParams(), {}).toString()).toBe("");
const qs = applyRange(new URLSearchParams(), { from: "2026-01-01" });
expect(qs.get("from")).toBe("2026-01-01");
expect(qs.has("to")).toBe(false);
expect(qs.get("tz_offset")).toBe(String(new Date().getTimezoneOffset()));
});
});
@@ -0,0 +1,58 @@
/**
* @file Tests for newBuildReload: after a reconnect the tab reloads once when
* the server's version is not this bundle's, never on the first connect,
* never for the same version, and not twice for one version.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "../eventBus";
import { reloadOnNewBuild } from "../newBuildReload";
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(serverVersion: string) {
const reload = vi.fn();
const store = new Map<string, string>();
const storage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
};
const off = reloadOnNewBuild({
bundle: "2.5.4",
health: () => Promise.resolve({ version: serverVersion }),
reload,
storage,
});
return { reload, off };
}
let off: (() => void) | undefined;
afterEach(() => off?.());
describe("reloadOnNewBuild", () => {
it("reloads once after a reconnect onto a new version", async () => {
const s = setup("2.5.5");
off = s.off;
eventBus.setConnected(true); // first connect: nothing to compare against yet
await flush();
expect(s.reload).not.toHaveBeenCalled();
eventBus.setConnected(false);
eventBus.setConnected(true);
await flush();
expect(s.reload).toHaveBeenCalledTimes(1);
eventBus.setConnected(false); // same version again: a failed UI build must not loop
eventBus.setConnected(true);
await flush();
expect(s.reload).toHaveBeenCalledTimes(1);
});
it("stays put when the server is this bundle's version", async () => {
const s = setup("2.5.4");
off = s.off;
eventBus.setConnected(false);
eventBus.setConnected(true);
await flush();
expect(s.reload).not.toHaveBeenCalled();
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* @file replay.test.ts
* @description Unit tests for the Replay tab's pure derivations: playback
* order, lifecycle markers and the session state at a cursor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { chronological, lifecycleMarkers, replayState } from "../replay";
import type { DashboardEvent } from "../types";
let nextId = 1;
function ev(event_type: string, sec: number, tool_name: string | null = null, agent = "main") {
return {
id: nextId++,
session_id: "s",
agent_id: agent,
event_type,
tool_name,
summary: null,
data: null,
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, sec)).toISOString(),
} as unknown as DashboardEvent;
}
const events = [
ev("SessionStart", 0),
ev("UserPromptSubmit", 1),
ev("PreToolUse", 2, "Read"),
ev("PostToolUse", 3, "Read"),
ev("PreToolUse", 4, "Bash"),
ev("PreToolUse", 5, "Read", "sub"),
ev("APIError", 6),
ev("PostToolUse", 7, "Bash"),
ev("Stop", 8),
];
describe("chronological", () => {
it("orders by time, then id", () => {
const tie = [ev("Stop", 9), ev("Stop", 9)];
const out = chronological([tie[1]!, events[2]!, tie[0]!, events[0]!]);
expect(out.map((e) => e.id)).toEqual([events[0]!.id, events[2]!.id, tie[0]!.id, tie[1]!.id]);
});
});
describe("lifecycleMarkers", () => {
it("marks lifecycle events at their sequence position", () => {
const m = lifecycleMarkers(events);
expect(m.map((x) => x.type)).toEqual(["SessionStart", "UserPromptSubmit", "APIError", "Stop"]);
expect(m[0]!.pct).toBe(0);
expect(m[3]!.pct).toBe(100);
expect(m[2]!.index).toBe(6);
});
it("handles a single event", () => {
expect(lifecycleMarkers([events[0]!])).toEqual([{ index: 0, type: "SessionStart", pct: 0 }]);
});
});
describe("replayState", () => {
it("is empty for no events", () => {
const s = replayState([], 0);
expect(s.status).toBeNull();
expect(s.currentTool).toBeNull();
expect(s.toolCounts).toEqual([]);
});
it("tracks the tool in flight and counts up to the cursor", () => {
const at4 = replayState(events, 4);
expect(at4.currentTool).toBe("Bash");
expect(at4.status).toBe("working");
expect(at4.prompts).toBe(1);
expect(at4.toolCounts).toEqual([
{ tool: "Bash", count: 1 },
{ tool: "Read", count: 1 },
]);
expect(at4.elapsedMs).toBe(4000);
const at6 = replayState(events, 6);
expect(at6.currentTool).toBe("Read"); // the subagent's, newest still open
expect(at6.errors).toBe(1);
expect(at6.status).toBe("error");
expect(at6.toolCounts[0]).toEqual({ tool: "Read", count: 2 });
});
it("closes a finished tool and an ended turn", () => {
expect(replayState(events, 3).currentTool).toBeNull();
// Stop on main leaves only the subagent's Read open.
expect(replayState(events, 8).currentTool).toBe("Read");
expect(replayState(events, 8).status).toBe("waiting");
});
it("clamps a cursor past the end", () => {
expect(replayState(events, 99).elapsedMs).toBe(8000);
});
});
+227 -47
View File
@@ -387,9 +387,12 @@ import type {
AlertEvent, AlertEvent,
AlertRule, AlertRule,
Analytics, Analytics,
BudgetStatus,
RulePreview,
CodexHooksStatus, CodexHooksStatus,
AntigravityHooksStatus, AntigravityHooksStatus,
CostResult, CostResult,
ToolQuota,
DashboardEvent, DashboardEvent,
Lane, Lane,
LaneChangeRange, LaneChangeRange,
@@ -423,10 +426,17 @@ import type {
WorkflowRun, WorkflowRun,
WorkflowRunsResponse, WorkflowRunsResponse,
WorkflowRunDetail, WorkflowRunDetail,
SessionSnapshotInfo,
SessionSnapshotPayload,
QueryPage,
ReportDefinition,
ReportInput,
ReportRun,
} from "./types"; } from "./types";
import { activeSourcesParam } from "./dataScope"; import { activeSourcesParam } from "./dataScope";
import i18n from "i18next"; import i18n from "i18next";
import { applyRange, type DateRange } from "./dateRange";
// Root path all endpoint paths are appended to. Kept relative (no host) so the // Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod. // same client bundle works behind the Vite dev proxy and in same-origin prod.
@@ -978,10 +988,15 @@ export const api = {
* day, the viewer's timezone offset is sent so the daily buckets align to * day, the viewer's timezone offset is sent so the daily buckets align to
* the user's local midnight. * the user's local midnight.
* *
* @param opts.tool Only sessions of this AI tool (`sessions.tool`).
* @param opts.range Local `from`/`to` days: counts, tokens and cost follow
* it; the daily series keep their own window.
* @returns {@link Analytics} — the chart-ready analytics payload. * @returns {@link Analytics} — the chart-ready analytics payload.
*/ */
get: () => { get: (opts: { tool?: string; range?: DateRange } = {}) => {
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) }); const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.tool) qs.set("tool", opts.tool);
applyRange(qs, opts.range);
applyScope(qs); applyScope(qs);
return request<Analytics>(`/analytics?${qs.toString()}`); return request<Analytics>(`/analytics?${qs.toString()}`);
}, },
@@ -1099,6 +1114,41 @@ export const api = {
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */ /** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }), remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
}, },
/** Start the dashboard at boot/login (systemd `--user` / LaunchAgent) — off
* by default; a change never starts or stops the running server. */
autostart: {
/** GET /api/settings/autostart */
get: () => request<AutostartStatus>("/settings/autostart"),
/** PUT /api/settings/autostart */
set: (enabled: boolean) =>
request<AutostartStatus>("/settings/autostart", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
},
/** Opt-in secret redaction of stored payloads and the folders whose hooks
* are dropped (`server/lib/privacy.js`). */
privacy: {
/** GET /api/settings/privacy */
get: () => request<PrivacySettings>("/settings/privacy"),
/** PUT /api/settings/privacy — any subset of the two fields */
save: (data: { redact_secrets?: boolean; ignore_cwd?: string[] }) =>
request<PrivacySettings & { ok: boolean }>("/settings/privacy", {
method: "PUT",
body: JSON.stringify(data),
}),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
/** GET /api/settings/push-token */
get: () => request<PushTokenStatus>("/settings/push-token"),
/** POST /api/settings/push-token — a new token; the old one stops working */
rotate: () =>
request<PushTokenStatus & { token: string }>("/settings/push-token", { method: "POST" }),
/** DELETE /api/settings/push-token — push ingest off */
revoke: () => request<PushTokenStatus>("/settings/push-token", { method: "DELETE" }),
},
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed /** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
* only on a click; agy runs it with no trust step. */ * only on a click; agy runs it with no trust step. */
antigravityHooks: { antigravityHooks: {
@@ -1255,6 +1305,8 @@ export const api = {
* "all" (which means "no filter"), keeping the default URL param-free. * "all" (which means "no filter"), keeping the default URL param-free.
* *
* @param status Optional lifecycle filter; "all" (or omitted) means no filter. * @param status Optional lifecycle filter; "all" (or omitted) means no filter.
* @param tool Optional AI tool id (`sessions.tool`).
* @param range Optional local date range: sessions started in it.
* @param sections Optional subset of the bundle to compute (`?sections=`); * @param sections Optional subset of the bundle to compute (`?sections=`);
* the server skips the rest, so a page reading a few cards asks for those. * the server skips the rest, so a page reading a few cards asks for those.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel, * @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
@@ -1262,10 +1314,14 @@ export const api = {
*/ */
get: <K extends keyof WorkflowData = keyof WorkflowData>( get: <K extends keyof WorkflowData = keyof WorkflowData>(
status?: string, status?: string,
tool?: string,
range?: DateRange,
sections?: readonly K[] sections?: readonly K[]
) => { ) => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (status && status !== "all") qs.set("status", status); if (status && status !== "all") qs.set("status", status);
if (tool) qs.set("tool", tool);
applyRange(qs, range);
if (sections?.length) qs.set("sections", sections.join(",")); if (sections?.length) qs.set("sections", sections.join(","));
const q = qs.toString(); const q = qs.toString();
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`); return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
@@ -1380,18 +1436,26 @@ export const api = {
* *
* @returns {@link CostResult} — the aggregate cost breakdown across sessions. * @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/ */
totalCost: (opts: { lane?: number; date?: string } = {}) => { totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
// Scope the aggregate to the active data-scope, exactly like the sessions / // Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector // stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total. // left the Dashboard "total cost" showing the un-narrowed global total.
// `lane`: only the sessions that workspace owns. // `lane`: only the sessions that workspace owns; `tool`: one AI tool's.
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) }); const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.lane !== undefined) qs.set("lane", String(opts.lane)); if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included. // `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date); if (opts.date) qs.set("date", opts.date);
if (opts.tool) qs.set("tool", opts.tool);
// `range`: total, breakdown and by_tool for those local days only.
applyRange(qs, opts.range);
applyScope(qs); applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`); return request<CostResult>(`/pricing/cost?${qs.toString()}`);
}, },
/** GET /api/pricing/quota - plan quota spent per native tool; `force` for a Refresh. */
quota: (force = false) =>
request<{ tools: ToolQuota[]; checkedAt: string }>(
`/pricing/quota${force ? "?force=1" : ""}`
),
/** /**
* GET /api/pricing/cost/:sessionId - cost for one session, priced as of * GET /api/pricing/cost/:sessionId - cost for one session, priced as of
* the session's start date. * the session's start date.
@@ -2035,13 +2099,6 @@ export const api = {
}), }),
}, },
// ──────────────────────────────── AI Router API ─────────────────────────────
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
* lane can be pointed at instead of the vendor's own API. Managed in the
* **AI Router** tab of Settings; attached to a lane when the lane is created.
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
/** AgentHub's own git credentials (issue #45), shared by every user of this /** AgentHub's own git credentials (issue #45), shared by every user of this
* install. No response ever carries a password — only `hasToken`. */ * install. No response ever carries a password — only `hasToken`. */
gitCredentials: { gitCredentials: {
@@ -2060,6 +2117,24 @@ export const api = {
{ method: "DELETE" } { method: "DELETE" }
), ),
}, },
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
snapshots: {
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
/** Freeze a session; `filePaths` redacts paths before anything is stored. */
create: (sessionId: string, opts?: { filePaths?: boolean }) =>
request<{ snapshot: SessionSnapshotInfo }>("/snapshots", {
method: "POST",
body: JSON.stringify({
session_id: sessionId,
redact: { file_paths: opts?.filePaths === true },
}),
}),
get: (token: string) =>
request<{ snapshot: SessionSnapshotInfo; payload: SessionSnapshotPayload }>(
`/snapshots/${encodeURIComponent(token)}`
),
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */ /** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: { pets: {
list: () => request<PetList>("/pets"), list: () => request<PetList>("/pets"),
@@ -2137,37 +2212,44 @@ export const api = {
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`; return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
}, },
}, },
aiRouters: { // ──────────────────────────────── AI Proxy API ──────────────────────────────
/** GET /api/ai-routers - every profile, the built-in `native` one first. */ /** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
list: () => request<{ items: AiRouter[] }>("/ai-routers"), * new session can run on instead of the vendor's own API. Managed in the
/** POST /api/ai-routers - save a new profile. */ * **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
create: (body: AiRouterInput) => *
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }), * No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
aiProxies: {
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
/** POST /api/ai-proxies - save a new profile. */
create: (body: AiProxyInput) =>
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
/** /**
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey` * PATCH /api/ai-proxies/:name - change a saved profile. Omitting `apiKey`
* keeps the stored one, which is what the edit form always does: it cannot * keeps the stored one, which is what the edit form always does: it cannot
* display a key, so it has none to send back. * display a key, so it has none to send back.
*/ */
update: (name: string, body: Partial<AiRouterInput>) => update: (name: string, body: Partial<AiProxyInput>) =>
request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, { request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/** /**
* POST /api/ai-routers/test - ask the endpoint itself whether it works, * POST /api/ai-proxies/test - ask the endpoint itself whether it works
* before a profile that cannot reach anything is saved onto a lane. * for Claude Code and for Codex, before a session finds out it does not.
*/ */
test: (body: AiRouterTestInput) => test: (body: AiProxyTestInput) =>
request<AiRouterTestResult>("/ai-routers/test", { request<AiProxyTestResult>("/ai-proxies/test", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/** /**
* DELETE /api/ai-routers/:name - also removes the session files that * DELETE /api/ai-proxies/:name - also removes the session files that
* copy its key. * copy its key.
*/ */
remove: (name: string) => remove: (name: string) =>
request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, { request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
method: "DELETE", method: "DELETE",
}), }),
}, },
@@ -2323,9 +2405,65 @@ export const api = {
*/ */
remove: (id: string) => remove: (id: string) =>
request<{ ok: true }>(`/alerts/rules/${encodeURIComponent(id)}`, { method: "DELETE" }), request<{ ok: true }>(`/alerts/rules/${encodeURIComponent(id)}`, { method: "DELETE" }),
/**
* POST /api/alerts/rules/preview - what a rule WOULD have fired over the
* last `days` (default 7) of recorded history. Saves and fires nothing.
*/
preview: (body: {
rule_type: AlertRule["rule_type"];
config: AlertRule["config"];
cooldown_seconds?: number;
days?: number;
}) =>
request<RulePreview>("/alerts/rules/preview", {
method: "POST",
body: JSON.stringify(body),
}),
}, },
}, },
/** Spend budgets — `budget` alert rules — with their current-period spend. */
budgets: {
/** GET /api/budgets/status */
status: () => request<{ budgets: BudgetStatus[] }>("/budgets/status"),
},
/** Query Explorer (`GET /api/query`, `server/routes/query.js`). */
query: {
/** One page of the filtered rows; `params` are the page's filters. */
page: (params: URLSearchParams) =>
request<QueryPage>(`/query?${applyScope(new URLSearchParams(params)).toString()}`),
/** A download link for the whole filtered set (≤ `export_cap` rows). */
exportUrl: (params: URLSearchParams, format: "csv" | "json") => {
const qs = applyScope(new URLSearchParams(params));
qs.delete("limit");
qs.delete("offset");
qs.set("format", format);
return `${BASE}/query?${qs.toString()}`;
},
},
/** Scheduled reports (`/api/reports`, `server/lib/reports.js`). */
reports: {
list: () => request<{ reports: ReportDefinition[] }>("/reports"),
create: (body: ReportInput) =>
request<{ report: ReportDefinition }>("/reports", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: Partial<ReportInput>) =>
request<{ report: ReportDefinition }>(`/reports/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/reports/${id}`, { method: "DELETE" }),
run: (id: number) => request<{ run: ReportRun }>(`/reports/${id}/run`, { method: "POST" }),
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
/** A run's file, as a download link. */
fileUrl: (id: number, runId: number, format: string) =>
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
},
// ───────────────────────────────── Webhooks API ───────────────────────────── // ───────────────────────────────── Webhooks API ─────────────────────────────
/** Outbound webhook target CRUD, provider metadata, test sends, and the /** Outbound webhook target CRUD, provider metadata, test sends, and the
* per-target delivery log. */ * per-target delivery log. */
@@ -3169,10 +3307,10 @@ export interface CcProfileUsage {
* user + project scope in one response. */ * user + project scope in one response. */
export type CcScope = "user" | "project" | "all"; export type CcScope = "user" | "project" | "all";
/** The per-tier model names an AI Router profile carries. Each maps to one env /** The per-tier model names an AI Proxy profile carries. Each maps to one env
* var Claude Code reads; a router that names its models once fills them all * var Claude Code reads; a proxy that names its models once fills them all
* from the profile's `model`. */ * from the profile's `model`. */
export interface AiRouterModels { export interface AiProxyModels {
opus: string; opus: string;
sonnet: string; sonnet: string;
haiku: string; haiku: string;
@@ -3180,7 +3318,6 @@ export interface AiRouterModels {
subagent: string; subagent: string;
} }
/** One saved AI Router profile, as the API returns it — **never** with a key. */
/** A saved git login, as the server reports it: never the password. */ /** A saved git login, as the server reports it: never the password. */
export interface GitCredential { export interface GitCredential {
protocol: "http" | "https"; protocol: "http" | "https";
@@ -3224,8 +3361,8 @@ export interface AiTool {
resume: boolean; resume: boolean;
/** Offers the per-run "Bypass permissions" switch. */ /** Offers the per-run "Bypass permissions" switch. */
bypass: boolean; bypass: boolean;
/** Can use an AI Router profile (Claude, Codex); others are native only. */ /** Can use an AI Proxy profile (Claude, Codex); others are native only. */
routerCapable: boolean; proxyCapable: boolean;
/** What Install runs; null when the catalog names no install. */ /** What Install runs; null when the catalog names no install. */
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null; install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */ /** What Update runs; `self` is the tool's own updater with these args. */
@@ -3283,37 +3420,47 @@ export interface AiToolJob {
log: string; log: string;
} }
export interface AiRouter { /** One saved AI Proxy profile, as the API returns it — **never** with a key. */
/** Identifier, derived from the label. Stable: a lane stores this. */ export interface AiProxy {
/** Identifier, derived from the label. Stable: a session's run row stores it. */
name: string; name: string;
label: string; label: string;
/** True only for the read-only `native` profile (the vendor's own API). */ /** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean; builtin: boolean;
/** Empty string for `native`, an http(s) URL otherwise. */ /** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
baseUrl: string; baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */ /** Whether a key is stored. The key itself never leaves the server. */
hasKey: boolean; hasKey: boolean;
/** "" for `native`, which has no model of its own. */
model: string; model: string;
models: AiRouterModels; /** Empty for `native`. */
/** Claude model id the router's own model names behave as; "" when unset. */ models: Partial<AiProxyModels>;
/** Claude model id the proxy's own model names behave as; "" when unset. */
behavesAs?: string; behavesAs?: string;
savedAt?: string; savedAt?: string;
} }
/** Outcome of POST /api/ai-routers/test. A router that answered with an error /** One probe of POST /api/ai-proxies/test. A proxy that answered with an
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is * error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
* its HTTP code (null when nothing answered at all), and `message` is its own * is its HTTP code (null when nothing answered at all), and `message` is its
* words — truncated server-side, never the key. */ * own words — truncated server-side, never the key. */
export interface AiRouterTestResult { export interface AiProxyProbe {
ok: boolean; ok: boolean;
status: number | null; status: number | null;
message: string; message: string;
} }
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored /** Outcome of POST /api/ai-proxies/test: Claude Code's `/v1/messages` and
* Codex's `/v1/responses`, probed side by side. */
export interface AiProxyTestResult {
claude: AiProxyProbe;
codex: AiProxyProbe;
}
/** Body for POST /api/ai-proxies/test. `name` lets the server reuse the stored
* key for a profile being edited — but only while `baseUrl` still matches the * key for a profile being edited — but only while `baseUrl` still matches the
* saved one, so a typed-in endpoint always needs a typed-in key. */ * saved one, so a typed-in endpoint always needs a typed-in key. */
export interface AiRouterTestInput { export interface AiProxyTestInput {
baseUrl: string; baseUrl: string;
model: string; model: string;
apiKey?: string; apiKey?: string;
@@ -3321,14 +3468,14 @@ export interface AiRouterTestInput {
} }
/** Body accepted when creating or updating a profile. */ /** Body accepted when creating or updating a profile. */
export interface AiRouterInput { export interface AiProxyInput {
label: string; label: string;
baseUrl: string; baseUrl: string;
/** Omit on update to keep the stored key. */ /** Omit on update to keep the stored key. */
apiKey?: string; apiKey?: string;
model: string; model: string;
/** Any tier left out is filled from `model`. */ /** Any tier left out is filled from `model`. */
models?: Partial<AiRouterModels>; models?: Partial<AiProxyModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */ /** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string; behavesAs?: string;
} }
@@ -3986,8 +4133,8 @@ export interface RunStartArgs {
resumeLast?: boolean; resumeLast?: boolean;
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */ /** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
bypass?: boolean; bypass?: boolean;
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */ /** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
router?: string; proxy?: string;
} }
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session /** A lane's tmux-backed terminal run — one per lane, id is the tmux session
@@ -4166,6 +4313,10 @@ export interface RemoteSource {
identity_file: string | null; identity_file: string | null;
/** Optional remote CLAUDE_HOME (default `~/.claude`). */ /** Optional remote CLAUDE_HOME (default `~/.claude`). */
remote_home: string | null; remote_home: string | null;
/** Whether Codex history (`<remote_codex_home>/sessions`) is pulled too. */
sync_codex?: boolean;
/** Optional remote CODEX_HOME (default `~/.codex`). */
remote_codex_home?: string | null;
/** Whether the background poller pulls this source. */ /** Whether the background poller pulls this source. */
enabled: boolean; enabled: boolean;
/** Last known sync state. */ /** Last known sync state. */
@@ -4182,6 +4333,9 @@ export interface RemoteSource {
errors?: number; errors?: number;
sessions_seen?: number; sessions_seen?: number;
sessions_tagged?: number; sessions_tagged?: number;
/** Codex journals read / sessions written, when Codex is pulled. */
codex_files?: number;
codex_sessions?: number;
} | null; } | null;
/** Live number of sessions currently attributed to this source. */ /** Live number of sessions currently attributed to this source. */
session_count?: number; session_count?: number;
@@ -4196,9 +4350,35 @@ export interface RemoteSourceInput {
ssh_port?: number | null; ssh_port?: number | null;
identity_file?: string | null; identity_file?: string | null;
remote_home?: string | null; remote_home?: string | null;
sync_codex?: boolean;
remote_codex_home?: string | null;
enabled?: boolean; enabled?: boolean;
} }
/** GET /api/settings/autostart. `available` false: no systemd `--user` /
* launchd GUI session here; `foreign`: a unit named agenthub AgentHub did not
* write, which it never touches. */
export interface AutostartStatus {
available: boolean;
kind: "systemd" | "launchd";
enabled: boolean;
foreign: boolean;
}
/** GET /api/settings/privacy. A field the environment set is `env_owned`
* and cannot be saved from the dashboard. */
export interface PrivacySettings {
redact_secrets: boolean;
ignore_cwd: string[];
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
env_owned: boolean;
}
/** Result of a connectivity probe (POST /:id/test). */ /** Result of a connectivity probe (POST /:id/test). */
export interface RemoteSourceTestResult { export interface RemoteSourceTestResult {
ok: boolean; ok: boolean;

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