Compare commits

...

8 Commits

Author SHA1 Message Date
nntrivi2001 f8acd5fc29 fix: v2.5.2 — plugin bootstrap exports before it runs (v2.5.1 update crash)
v2.5.1's update died at "bootstrap" on every platform: boot.npmCommand is
not a function. Run as the entry, plugin-bootstrap.js calls bootstrap() →
plugin-open.js, which requires plugin-bootstrap back and got the module.exports
of that moment — {} — because it was assigned at the bottom, below the entry
block. plugin-open.js already exported first for the same reason.

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

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

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

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00
nntrivi2001 837da6e5de fix: v2.3.1 — correct version, say why a plugin update failed, gitignored files in Files
- v2.3.0's tree reported 2.2.1, so every install saw an update forever;
  package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the
  snapshot now read 2.3.1
- A failed plugin-update step records the tail of the command's stdout and
  stderr plus its exit code or signal (failureText); plugin-bootstrap.js
  prints its refusal on stdout, which the verdict used to drop. Update all
  uses the same text
- updates.test.js never applies an update: it skips when the checkout is
  behind a release (apply ran npm run setup and pruned dev dependencies)
- Files tree lists gitignored files and folders, named in orange
  (addIgnored in git-diff.js); change-tree rows indent in whole steps
2026-10-06 16:22:53 +07:00
nntrivi2001 7ca1134c3d test(DiffEditor): wait for the split view's revert column, drawn on a measure pass 2026-10-06 15:43:47 +07:00
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00
nntrivi2001 f0500dc592 feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
2026-10-06 12:36:06 +07:00
221 changed files with 18081 additions and 1456 deletions
+14
View File
@@ -131,6 +131,20 @@
# liveness/stale sweeps (which skip them). (default: 600000 = 10min).
# 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) ────────────
# Directory dashboard-managed worktrees are provisioned under. It is also the
# 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
.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
#
# Pre-commit: auto-format staged files, then run the full test suite.
# The commit is ABORTED unless every test passes.
# Pre-commit: auto-format staged files, then run the tests the commit needs —
# 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"
# 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
# the gate strict without making commits a dice roll on machine load.
run_suite() {
# $1 = human label, $2 = npm script
# $1 = human label, $2 = shell command
echo "🧪 Running $1 tests..."
if npm run "$2"; then
if sh -c "$2"; then
return 0
fi
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 " If this keeps happening, the flaky test above deserves a real fix."
return 0
@@ -59,7 +61,68 @@ run_suite() {
return 1
}
run_suite backend test:server
run_suite frontend test:client
# The frontend suite needs NODE_ENV=test: a shell exporting
# 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."
+30 -5
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/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/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 |
| `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/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 |
| `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()` |
| `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 |
@@ -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/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/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/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/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` | 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-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 |
@@ -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/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/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. |
| `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 |
@@ -850,7 +852,7 @@ erDiagram
alert_rules {
TEXT id PK "UUID"
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"
INTEGER enabled "1|0"
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
The repository includes a triple extension strategy:
+9 -4
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
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
service install`), so the dashboard comes back after every login with no
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
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
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
+26 -2
View File
@@ -86,6 +86,12 @@ Internal build — all rights reserved.
notification type can pick its own.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
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
@@ -197,7 +203,10 @@ agenthub service uninstall # disable and remove it
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
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`.
## 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
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
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`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
```
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
@@ -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 |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `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 |
| `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
độ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.
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
`docs/PLUGINS.md`.
@@ -269,8 +272,13 @@ npm run test:server # node:test
npm run test:client # Vitest
```
Cả hai phải xanh (pass) trước khi commit; pre-commit hook chạy chúng cộng
thêm Prettier.
Cả hai phải xanh (pass) trước khi commit. Pre-commit hook (`.husky/pre-commit`)
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
+66 -14
View File
@@ -141,21 +141,10 @@ function termWidth() {
/**
* Resolve the dashboard base URL. Env override first (matches the hook
* 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() {
const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT;
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";
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
}
/** 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,
identity_file: flags.identity != null ? String(flags.identity) : 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,
};
const r = await post("/api/remote-sources", body);
@@ -2797,7 +2788,7 @@ const COMMAND_GROUPS = [
[
"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 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",
[
@@ -3664,8 +3671,43 @@ const SUBCOMMANDS = {
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
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
* ServerDownError (for the caller's offline routing) or call process.exit
* for usage/validation failures. `cmd === "repl"` is handled by the entry
@@ -3800,6 +3842,16 @@ async function runCommand(argv) {
process.exitCode = 1;
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":
return cmdOpen();
case "version":
+7 -3
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
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/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.
@@ -199,13 +199,16 @@ client/
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── 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
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │
│ ├── 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
│ │ ├── QueryExplorer.tsx # /query: filter sessions / events / agents, sort, page, export CSV/JSON (GET /api/query)
│ │ ├── 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
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
│ │ ├── Settings.tsx
@@ -501,6 +504,7 @@ function App() {
<Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} />
<Route path="query" element={<QueryExplorer />} />
{/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
+112
View File
@@ -8,6 +8,10 @@
"name": "agenthub-client",
"version": "1.0.0",
"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/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0",
@@ -410,6 +414,66 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1052,6 +1116,36 @@
"@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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2580,6 +2674,12 @@
"dev": true,
"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": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -5702,6 +5802,12 @@
"dev": true,
"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": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -6402,6 +6508,12 @@
"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": {
"version": "5.0.0",
"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"
},
"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/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0",
+29
View File
@@ -70,6 +70,7 @@ import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus";
import { copyOnCtrlShiftC } from "./lib/keys";
import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
@@ -81,6 +82,12 @@ const SettingsHub = lazy(() =>
const SessionDetail = lazy(() =>
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
);
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const QueryExplorer = lazy(() =>
import("./pages/QueryExplorer").then((m) => ({ default: m.QueryExplorer }))
);
function PageFallback() {
const { t } = useTranslation();
@@ -128,6 +135,12 @@ export default function App() {
return () => window.removeEventListener("vite:preloadError", onPreloadError);
}, []);
// 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 (
<>
<SplashScreen />
@@ -151,6 +164,22 @@ export default function App() {
</Page>
}
/>
<Route
path="snapshot/:token"
element={
<Page>
<SnapshotView />
</Page>
}
/>
<Route
path="query"
element={
<Page>
<QueryExplorer />
</Page>
}
/>
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route
path="workflows"
+252 -12
View File
@@ -70,6 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
Eye,
ListChecks,
Pencil,
Plus,
@@ -87,9 +89,10 @@ import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { fmtCost, timeAgo } from "../lib/format";
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;
@@ -124,6 +127,8 @@ const RULE_TYPES: AlertRuleType[] = [
"inactivity",
"status_duration",
"token_threshold",
"cost_threshold",
"budget",
];
type TabKey = "rules" | "channels" | "activity";
@@ -139,6 +144,11 @@ interface RuleFormState {
minutes: string;
status: "working" | "waiting";
total_tokens: string;
usd: string;
period: "day" | "week" | "month";
limit_usd: string;
thresholds: string;
budget_tool: string;
cooldown_seconds: string;
}
@@ -153,6 +163,11 @@ const EMPTY_FORM: RuleFormState = {
minutes: "10",
status: "working",
total_tokens: "1000000",
usd: "5",
period: "month",
limit_usd: "100",
thresholds: "80, 100",
budget_tool: "",
cooldown_seconds: "300",
};
@@ -171,12 +186,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
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),
};
}
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"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -198,6 +229,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
return { status: form.status, minutes: parseFloat(form.minutes) };
case "token_threshold":
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 });
case "token_threshold":
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 [saving, setSaving] = useState(false);
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>();
// A rule-menu item: close the menu, then act on the rule it was opened on.
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
@@ -322,10 +376,35 @@ export function AlertsNotifications() {
});
}, [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 = () => {
setForm(EMPTY_FORM);
setPreview(null);
setEditingId(null);
setFormOpen(false);
setFormError(null);
@@ -333,6 +412,7 @@ export function AlertsNotifications() {
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setPreview(null);
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
@@ -417,7 +497,10 @@ export function AlertsNotifications() {
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
(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 }[] = [
{
@@ -680,6 +763,100 @@ export function AlertsNotifications() {
</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">
<label className="block text-xs text-fg-secondary">
<span className="mb-1.5 flex items-center gap-1">
@@ -701,14 +878,29 @@ export function AlertsNotifications() {
</span>
)}
</label>
<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 className="flex items-center gap-2">
<button
onClick={onPreview}
disabled={!canSubmit || previewing}
title={t("rules.previewHint")}
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye className="w-3.5 h-3.5" />
{previewing ? t("rules.previewing") : t("rules.preview")}
</button>
<button
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>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
</div>
@@ -927,3 +1119,51 @@ export function AlertsNotifications() {
</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
View File
@@ -28,6 +28,8 @@ export function DocumentTitle() {
title = t("workspace");
} else if (path.startsWith("/settings")) {
title = t("settings");
} else if (path.startsWith("/query")) {
title = t("query");
} else if (path !== "/") {
title = t("notFound");
}
+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
* 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
* With no stored preference the sidebar starts collapsed below `lg` (see
* {@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 { 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. */
export const DRAWER_QUERY = "(max-width: 767.98px)";
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
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.
useEffect(closeDrawer, [pathname, search, closeDrawer]);
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen}
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
id="main-content"
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={{
marginLeft: sidebarWidth,
width: `calc(100% - ${sidebarWidth})`,
+48 -3
View File
@@ -1,7 +1,8 @@
/**
* @file RemoteSources.tsx
* @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).
*
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
@@ -97,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
ssh_port: null,
identity_file: "",
remote_home: "",
sync_codex: false,
remote_codex_home: "",
enabled: true,
};
@@ -219,6 +222,8 @@ export function RemoteSources() {
ssh_port: s.ssh_port,
identity_file: s.identity_file || "",
remote_home: s.remote_home || "",
sync_codex: !!s.sync_codex,
remote_codex_home: s.remote_codex_home || "",
enabled: s.enabled,
});
setEditingId(s.id);
@@ -242,6 +247,8 @@ export function RemoteSources() {
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
identity_file: form.identity_file?.trim() ? form.identity_file.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,
};
try {
@@ -591,6 +598,31 @@ export function RemoteSources() {
</p>
</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
checked={!!form.enabled}
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
@@ -674,6 +706,9 @@ export function RemoteSources() {
{s.host}
{s.ssh_port ? `:${s.ssh_port}` : ""}
{s.remote_home ? ` · ${s.remote_home}` : ""}
{s.sync_codex
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
: ""}
</div>
<div className="text-[11px] text-fg-muted mt-1">
{s.last_sync_at
@@ -689,9 +724,19 @@ export function RemoteSources() {
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
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>
{s.status === "error" && s.last_error && (
<div className="text-[11px] text-status-danger mt-1 break-words">
{/* A sync that pulled one CLI's history but not the other's is
`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}
</div>
)}
+1 -1
View File
@@ -7,7 +7,7 @@
* 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
* (`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>
*/
+41 -8
View File
@@ -5,7 +5,10 @@
* animation state (idle, run right, run left, waving, jumping, failed,
* 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
* 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
* back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,11 +33,28 @@ const ROW: Record<Mood, number> = {
worried: 5, // failed
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({
id,
kind,
mood,
action,
size,
reducedMotion,
onBroken,
@@ -42,23 +62,36 @@ export function PetSprite({
id: string;
kind: "codex" | "image";
mood: Mood;
action?: PetAction | null;
size: number;
reducedMotion: boolean;
onBroken: () => void;
}) {
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);
useEffect(() => {
setFrame(0);
if (kind !== "codex" || reducedMotion) return;
const timer = window.setInterval(
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
mood === "sleeping" ? 400 : 125
);
return () => window.clearInterval(timer);
}, [kind, row, mood, reducedMotion]);
const count = FRAMES[row] ?? 1;
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
let f = 0;
let timer = 0;
const tick = () => {
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.
useEffect(() => {
+6 -1
View File
@@ -203,6 +203,7 @@ export function Sagi() {
const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useSagiBrain();
@@ -315,7 +316,7 @@ export function Sagi() {
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/pet");
navigate("/settings/appearance#pet");
}}
/>
</SagiFlyout>
@@ -331,6 +332,8 @@ export function Sagi() {
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
@@ -358,6 +361,8 @@ export function Sagi() {
id={pet.id}
kind={pet.kind}
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}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
@@ -94,6 +94,8 @@ describe("PetSprite", () => {
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
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");
rerender(
<PetSprite
@@ -108,6 +110,45 @@ describe("PetSprite", () => {
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", () => {
render(
<PetSprite
@@ -159,7 +200,7 @@ describe("PetMenu", () => {
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
// 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();
});
@@ -5,7 +5,16 @@
*/
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 { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
@@ -192,3 +201,22 @@ describe("Sagi widget", () => {
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
* 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
* 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
+30 -3
View File
@@ -7,6 +7,8 @@
* 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
* 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
* bottom-right just above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -83,6 +85,8 @@ const DRAG_THRESHOLD = 5;
const STATUS_BAR = 24;
// One arrow-key step, in px.
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 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. */
openUp: boolean;
dragging: boolean;
/** The way the avatar is moving horizontally right now, if it is. */
moving: "left" | "right" | null;
onPointerDown: (e: ReactPointerEvent) => void;
onPointerMove: (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 [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
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 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 */
}
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
lastX.current = e.clientX;
movedRef.current = false;
},
[screen.left, screen.top]
@@ -172,8 +188,14 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
);
liveRef.current = { 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(
@@ -193,12 +215,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
sagiPrefs.setPos(next);
setPos(next);
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;
startRef.current = null;
movedRef.current = false;
},
[size]
[size, heading]
);
const onKeyDown = useCallback(
@@ -214,10 +239,11 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
}
if (!next) return;
e.preventDefault();
if (next.side !== pos.side) heading(next.side);
sagiPrefs.setPos(next);
setPos(next);
},
[pos, size]
[pos, size, heading]
);
const consumeDrag = useCallback(() => {
@@ -233,6 +259,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
side: pos.side,
openUp: screen.top + size / 2 > vh() / 2,
dragging: drag !== null,
moving,
onPointerDown,
onPointerMove,
onPointerUp,
+9 -4
View File
@@ -76,6 +76,8 @@ import type { Agent, Session, SessionStats } from "../lib/types";
interface SessionOverviewProps {
session: Session;
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. */
@@ -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 [stats, setStats] = useState<SessionStats | null>(null);
const [fetched, setStats] = useState<SessionStats | null>(null);
const stats = frozen ?? fetched;
const fetchingRef = useRef(false);
// Tick clock every 30s so a still-active session's "duration" tile stays current.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (session.status !== "active") return;
if (frozen || session.status !== "active") return;
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, [session.status]);
}, [frozen, session.status]);
// 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).
@@ -198,6 +201,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Initial load + reload when the session id changes.
useEffect(() => {
if (frozen) return;
fetchStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
@@ -206,6 +210,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// stream still refreshes) and again after a reconnect.
useLiveRefresh(() => void fetchStats(), {
throttleMs: REFRESH_THROTTLE_MS,
enabled: !frozen,
match: (msg) => {
if (isRemoteDataRefreshMessage(msg)) return true;
if (
+20 -9
View File
@@ -69,6 +69,7 @@ import {
ChevronUp,
ChevronDown,
FolderTree,
TableProperties,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
@@ -83,6 +84,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/query", icon: TableProperties, key: "nav:query" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
@@ -170,6 +172,10 @@ interface SidebarProps {
onUpdateAvailableChange?: (available: boolean) => void;
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
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({
@@ -180,6 +186,8 @@ export function Sidebar({
onConnectionOpenChange,
onUpdateAvailableChange,
drawer,
width = 240,
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const asideRef = useRef<HTMLElement>(null);
@@ -396,13 +404,15 @@ export function Sidebar({
onUpdateAvailableChange?.(updateAvailable);
}, [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 }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`;
return (
@@ -421,8 +431,9 @@ export function Sidebar({
? "visible translate-x-0 shadow-2xl transition-transform"
: "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="flex flex-col items-center justify-center">
@@ -451,7 +462,7 @@ export function Sidebar({
Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */}
<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 }) => {
const label = t(key);
return (
@@ -475,7 +486,7 @@ export function Sidebar({
onClick={onToggle}
title={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" />
</button>
@@ -528,8 +539,8 @@ export function Sidebar({
data-testid="sidebar-toggle"
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")}
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
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" : "h-8 w-8 flex-shrink-0"
}`}
>
{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,
* `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
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
* its fullest window, live account only — and, on hover or focus, a panel
* above grouping every account per tool (saved Claude profiles too): name,
* sign-in expiry, and a row per window with its bar, percent and reset time.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 { useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import type { CostResult } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
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 {
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] w-[19rem] max-w-[calc(100vw-1rem)] 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({
wsConnected,
left,
@@ -85,6 +342,8 @@ export function StatusBar({
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
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 shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
@@ -124,6 +383,13 @@ export function StatusBar({
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
@@ -213,6 +479,21 @@ export function StatusBar({
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>
);
}
+9 -4
View File
@@ -29,8 +29,9 @@
* ## Dismissal persistence
* Dismissals are keyed in `localStorage` by the release tag when there is one,
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
* a dismissed one stays dismissed. Settings can reset dismissal via the
* `dashboard:reset-update-dismissal` window event.
* a dismissed one stays dismissed. The connection modal's "Check for updates"
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -184,7 +185,11 @@ export function UpdateNotifier() {
}, [syncFromPayload]);
useEffect(() => {
const handler = () => setDismissedSha(null);
// An explicit "Check for updates" undoes both kinds of close.
const handler = () => {
setDismissedSha(null);
setClosedKey(null);
};
window.addEventListener("dashboard:reset-update-dismissal", handler);
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
}, []);
@@ -313,7 +318,7 @@ export function UpdateNotifier() {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
@@ -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
* 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,
* 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>
*/
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
vi.mock("../StatusBar", () => ({ StatusBar: () => 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";
function viewport(width: number) {
@@ -34,6 +35,7 @@ function viewport(width: number) {
afterEach(() => {
vi.unstubAllGlobals();
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
localStorage.removeItem("agenthub.sidebar.width");
});
describe("loadCollapsed", () => {
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
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
* 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
* 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>
*/
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 userEvent from "@testing-library/user-event";
@@ -68,12 +69,63 @@ vi.mock("../../lib/api", () => ({
],
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) => ({
total_cost: id === "s-live" ? 4.5 : 7,
breakdown: [],
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");
});
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", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
@@ -144,4 +204,52 @@ describe("StatusBar", () => {
});
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);
});
});
@@ -238,6 +238,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
});
it("Check for updates re-opens a modal closed with Later", async () => {
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
});
it("ignores Escape while an update is applying", async () => {
applyMock.mockReturnValue(new Promise(() => {}));
render(<UpdateNotifier />);
@@ -82,6 +82,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
forgetHistory: vi.fn(async () => ({ removed: "" })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
@@ -417,6 +418,30 @@ describe("WorkspaceTree", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("removes a closed terminal from its hover X and its right-click menu", async () => {
const shell = (id: string) => ({
id,
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
});
vi.mocked(api.run.history).mockImplementation(
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
await user.click(screen.getByText("Remove from list"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
});
it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
+330
View File
@@ -0,0 +1,330 @@
/**
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
* file's original text (HEAD, or a branch's merge base), editable while the
* diff re-colours as you type. `unified` is one editor with the removed lines
* inline above their replacement (`unifiedMergeView`); `split` is the
* original read-only on the left beside the editable file (`MergeView`).
* `collapse` folds unchanged stretches away — the panel's Changes view —
* and every changed block carries a Revert that puts the original back in the
* editor (nothing is written until Save). Green marks a line added whole, red
* a line removed whole, blue a line edited in place (it keeps some of its
* text), its edit in a stronger blue — the same scheme as the read-only diff in
* LaneDiffView. Syntax colours come from the dashboard's own per-line
* tokenizer (`lib/highlight.ts`), like every other code view here.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { EditorState, RangeSetBuilder } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
drawSelection,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
import { highlight, tokenClass } from "../../lib/highlight";
/**
* True when a letter or digit of a line (`text`, starting at document offset
* `from`) lies outside every changed range: an edit inside the line, not a
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
* a `}`) does not make two lines the same line.
*/
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
for (let i = 0; i < text.length; i += 1) {
if (!/\w/.test(text[i]!)) continue;
const at = from + i;
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
}
return false;
}
const modLine = Decoration.line({ class: "cm-ah-mod" });
const marks = new Map<string, Decoration>();
const mark = (cls: string) => {
let d = marks.get(cls);
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
return d;
};
/** Syntax marks and the edited-in-place class for the visible lines — on
* either side of a split view (`a` is the original). */
function decorate(view: EditorView, lang: string): DecorationSet {
const out = new RangeSetBuilder<Decoration>();
const info = getChunks(view.state);
const a = info?.side === "a";
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
const chunks = info?.chunks ?? [];
const doc = view.state.doc;
let last = 0;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = doc.lineAt(pos);
pos = line.to + 1;
if (line.number <= last) continue;
last = line.number;
const c = chunks.find((k) => {
const [from, to, end] = span(k);
return to! > from! && line.from >= from! && line.from <= end!;
});
const at0 = c ? span(c)[0]! : 0;
if (
c &&
keepsText(
line.text,
line.from,
c.changes.map((ch) =>
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
)
)
) {
out.add(line.from, line.from, modLine);
}
let at = line.from;
for (const tok of highlight(line.text, lang)) {
const cls = tokenClass(tok.type);
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
at += tok.text.length;
}
}
}
return out.finish();
}
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
* classed after the fact, by `keepsText`'s rule: text outside its
* `.cm-deletedText` spans is text the line kept. */
function classDeleted(root: HTMLElement) {
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
let kept = "";
(line.firstElementChild ?? line).childNodes.forEach((n) => {
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
kept += n.textContent ?? "";
}
});
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
});
}
const diffColours = (lang: string) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = decorate(view, lang);
this.classLater(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
this.classLater(u.view);
}
// After CodeMirror has drawn, and before the browser paints.
classLater(view: EditorView) {
view.requestMeasure({
key: this,
read: () => null,
write: () => classDeleted(view.contentDOM),
});
}
},
{ decorations: (p) => p.decorations }
);
const GREEN = "rgb(var(--status-success) / 0.18)";
const RED = "rgb(var(--status-danger) / 0.18)";
const BLUE = "rgb(var(--diff-mod) / 0.18)";
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
// base theme, whose selectors carry an extra light/dark scope class.
const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({
"&": {
height: "100%",
fontSize: "11px",
backgroundColor: "rgb(var(--surface-0))",
color: "rgb(var(--fg-primary))",
},
"&.cm-focused": { outline: "none" },
".cm-scroller": {
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
lineHeight: "1.45",
},
".cm-gutters": {
backgroundColor: "rgb(var(--surface-1))",
color: "rgb(var(--fg-muted))",
border: "none",
},
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgb(var(--accent) / 0.3)",
},
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${M} .cm-changedText`]: { background: "none" },
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
// The original, left of a split view: its changed lines are removals.
[`${A} .cm-changedLine`]: { backgroundColor: RED },
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${A} .cm-changedText`]: { background: "none" },
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
"&.cm-editor.cm-editor .cm-collapsedLines": {
color: "rgb(var(--fg-muted))",
background: "rgb(var(--surface-2))",
},
".cm-chunkButtons": { zIndex: "1" },
});
/** The Revert button on a changed block; @codemirror/merge wires the click.
* Between split columns there is room for an arrow only (`→`: left onto right). */
function revertButton(label: string, arrow = false) {
const b = document.createElement("button");
b.type = "button";
b.textContent = arrow ? "→" : label;
b.title = label;
b.setAttribute("aria-label", label);
b.className =
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
return b;
}
export default function DiffEditor({
value,
original,
lang,
layout = "unified",
collapse = false,
label,
revertLabel,
onChange,
onSave,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
original: string;
lang: string;
/** One editor with removals inline, or the original beside the file. */
layout?: "unified" | "split";
/** Fold unchanged stretches away: only the changes and their context. */
collapse?: boolean;
label: string;
/** The text of each changed block's Revert button. */
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
}) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave });
cb.current = { onChange, onSave };
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme];
const editing = [
...common,
highlightActiveLineGutter(),
history(),
drawSelection(),
highlightActiveLine(),
keymap.of([
{
key: "Mod-s",
preventDefault: true,
run: () => {
cb.current.onSave();
return true;
},
},
indentWithTab,
...defaultKeymap,
...historyKeymap,
]),
EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
}),
];
let v: EditorView;
let destroy: () => void;
if (layout === "split") {
const mv = new MergeView({
parent: host.current!,
a: {
doc: original,
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
},
b: { doc: value, extensions: editing },
revertControls: "a-to-b",
renderRevertControl: () => revertButton(revertLabel, true),
collapseUnchanged: folded,
});
v = mv.b;
destroy = () => mv.destroy();
} else {
v = new EditorView({
parent: host.current!,
state: EditorState.create({
doc: value,
extensions: [
...editing,
unifiedMergeView({
original,
syntaxHighlightDeletions: false,
collapseUnchanged: folded,
// Accept would only hide a block; Revert is the one that edits.
mergeControls: (type, action) => {
const b = revertButton(revertLabel);
if (type === "accept") b.hidden = true;
else b.onmousedown = action;
return b;
},
}),
],
}),
});
destroy = () => v.destroy();
}
v.focus();
view.current = v;
return destroy;
// One mount per view: the parent keys this component by file and shape.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => {
const v = view.current;
if (v && v.state.doc.toString() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
}
}, [value]);
return (
<div
ref={host}
data-testid="lane-file-editor"
data-layout={layout}
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
/>
);
}
File diff suppressed because it is too large Load Diff
+24 -44
View File
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
export const ROW_PX = 16;
const OVERSCAN = 20;
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`
@@ -165,7 +166,7 @@ function Windowed({
}
/** 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() || "";
return canonicalLang(ext === filePath ? "plain" : ext);
}
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
return (
<>
<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} />
</span>
<Code text={text.slice(to)} lang={lang} />
@@ -209,9 +210,10 @@ function emphasis(
while (start < max && del[start] === add[start]) start += 1;
let end = 0;
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
// Nothing in common at either edge: the whole line is the change, and
// highlighting all of it adds noise on top of the row tint.
if (start === 0 && end === 0) return null;
// No letter or digit in common at either edge (blanks, a `;`, a `}` do not
// count): the whole line is the change — a removed line and an added one,
// 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 {
del: [start, del.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.
* `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({
tint,
style,
@@ -310,7 +313,7 @@ function Row({
return (
<div
className={`flex w-max min-w-full ${tint}`}
style={style}
style={style ?? MIN_ROW}
data-diff-item={style ? "" : undefined}
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
* separate from context at a glance on every theme (the /10 fill alone did not). */
function rowTint(type: LaneDiffLine["type"] | null) {
* separate from context at a glance on every theme (the /10 fill alone did not).
* 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")
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
return "";
}
function markerClass(type: LaneDiffLine["type"]) {
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 === "del") return "font-bold text-status-danger";
return "text-fg-muted";
@@ -485,11 +492,11 @@ function UnifiedLine({
}) {
const anchor = useAnchor(start);
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.newLine} commentable />
<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" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
@@ -558,7 +565,7 @@ function SplitLine({
const line = side === "left" ? row.left : row.right;
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
<Gutter
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
commentable={side === "right"}
@@ -941,16 +948,12 @@ export function LaneFileView({
lines,
truncated,
fill = false,
onEditAt,
}: {
path: string;
lines: string[];
truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */
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 lang = langFor(path);
@@ -959,12 +962,9 @@ export function LaneFileView({
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
{/* `contents`: a line marker for the click handler, not a layout box. */}
<span data-ln={i} className="contents">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
);
@@ -972,27 +972,7 @@ export function LaneFileView({
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
tabIndex={onEditAt ? 0 : undefined}
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" : ""
}`}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
>
{win.on ? (
<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,
* head, subject, and the uncommitted counts) and the git action bar under it
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
* the open panel lists those files itself, so its header chip opens nothing.
* head, subject) and the git action bar under it (`LaneGitBar`); plus the
* uncommitted counts drawn vertical on the CLOSED Changes rail
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
* by the real total, listing the server's first 20, its "… more files" opening
* the Changes panel — the open panel lists those files itself, so its header
* shows no counts.
*
* It used to sit in the lane card, a column away from the diff it describes —
* 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({
files,
hidden,
onShowAll,
anchor,
side = "below",
onPointerEnter,
@@ -109,6 +112,8 @@ function LaneGitFilesPanel({
}: {
files: LaneGitFile[];
hidden: number;
/** Opens the Changes panel, which lists every file the cap left out. */
onShowAll?: () => void;
/** The counts button this panel hangs off. */
anchor: HTMLElement | null;
/** `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.
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
const total = files.length + hidden;
return createPortal(
<div
ref={panelRef}
data-testid="lane-git-files"
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 panel answers the same enter/leave pair the trigger does.
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"
>
<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="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
@@ -206,30 +212,40 @@ function LaneGitFilesPanel({
);
})}
</ul>
{hidden > 0 && (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
)}
{hidden > 0 &&
(onShowAll ? (
<button
type="button"
data-testid="lane-git-files-more"
onClick={onShowAll}
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
>
{t("git.filesMore", { count: hidden })}
</button>
) : (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
))}
</div>,
document.body
);
}
/**
* The uncommitted counts as a chip. With `details` it opens the per-file panel
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
* else lists the files. In the open panel the list below already does, so the
* header shows the chip alone.
* The uncommitted counts as a chip that opens the per-file panel on hover,
* focus or click — the closed Changes rail, `vertical`, where nothing else
* lists the files.
*/
export function LaneGitCounts({
git,
vertical = false,
details = true,
onShowAll,
}: {
git: Extract<LaneGitFacts, { available: true }>;
vertical?: boolean;
details?: boolean;
/** See `LaneGitFilesPanel`. */
onShowAll?: () => void;
}) {
const { t } = useTranslation(["lanes"]);
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 ${
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 (
<>
<button
@@ -288,6 +297,13 @@ export function LaneGitCounts({
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
onShowAll={
onShowAll &&
(() => {
setFilesOpen(false);
onShowAll();
})
}
anchor={anchor}
side={vertical ? "left" : "below"}
onPointerEnter={openFiles}
@@ -300,9 +316,18 @@ export function LaneGitCounts({
/** The counts on the closed Changes rail: its own facts read, polled only
* while the rail is shown (the open panel owns the read otherwise). */
export function LaneGitRailCounts({ laneId, session }: { 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);
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
}
export function LaneGitHeader({
@@ -339,7 +364,6 @@ export function LaneGitHeader({
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
<LaneGitCounts git={git} details={false} />
</>
);
return (
+17 -1
View File
@@ -125,7 +125,23 @@ function HomeBody({
const entry = aiTools.byId(tool);
const claude = tool === "claude";
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 [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
+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 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>
)}
{!renaming && (
@@ -14,7 +14,8 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -31,7 +32,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
@@ -42,7 +43,7 @@ import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
@@ -297,6 +298,15 @@ export function WorkspaceSessionList({
toast.error(err);
}
};
const closedCtx = useContextMenu<RunHistoryItem>();
const forgetShell = async (r: RunHistoryItem) => {
try {
await api.run.forgetHistory(r.id);
setClosedShells((all) => all.filter((x) => x.id !== r.id));
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -427,7 +437,7 @@ export function WorkspaceSessionList({
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
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}
{confirmArchive}
</>
@@ -435,7 +445,11 @@ export function WorkspaceSessionList({
}
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) => (
<li key={r.id}>
<button
@@ -450,7 +464,7 @@ export function WorkspaceSessionList({
}
onContextMenu={(e) => ctx.open(e, r)}
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
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -458,19 +472,19 @@ export function WorkspaceSessionList({
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt, true) : null}
</span>
{r.tool === "shell" ? (
<>
<span className="truncate">{t("tabs.terminal")}</span>
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
</>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
) : (
<>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
)}
</button>
</li>
@@ -480,7 +494,7 @@ export function WorkspaceSessionList({
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) {
return (
<li key={s.id} className="py-0.5 pl-7 pr-2">
<li key={s.id} className="py-0.5 pr-2">
<input
data-testid={`workspace-session-rename-${s.id}`}
aria-label={t("tree.renameSession")}
@@ -534,7 +548,7 @@ export function WorkspaceSessionList({
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\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
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -554,9 +568,12 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at, true)}
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
/>
</button>
<button
@@ -575,13 +592,14 @@ export function WorkspaceSessionList({
);
})}
{closed.map((r) => (
<li key={r.id}>
<li key={r.id} className="group relative">
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-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
aria-hidden
@@ -590,9 +608,22 @@ export function WorkspaceSessionList({
<span className="sr-only">{t("tree.state.idle")}</span>
<span className="truncate">{t("tabs.terminal")}</span>
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)}
{timeAgo(r.ended_at ?? r.started_at, true)}
</span>
<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 group-hover:invisible"
/>
</button>
<button
type="button"
data-testid={`workspace-terminal-forget-${r.id}`}
onClick={() => void forgetShell(r)}
title={t("tree.forgetTerminal")}
aria-label={t("tree.forgetTerminal")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
@@ -602,7 +633,7 @@ export function WorkspaceSessionList({
type="button"
data-testid={`workspace-sessions-more-${lane.id}`}
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 })}
</button>
@@ -619,6 +650,42 @@ export function WorkspaceSessionList({
onClose={ctx.close}
/>
)}
{closedCtx.menu && (
<ContextMenu
x={closedCtx.menu.x}
y={closedCtx.menu.y}
onClose={closedCtx.close}
testId={`terminal-menu-${closedCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(false);
}}
>
{t("tree.reopenTerminal")}
</MenuItem>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(true);
}}
>
{t("sessionMenu.openSplit")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const item = closedCtx.menu!.item;
closedCtx.close();
void forgetShell(item);
}}
>
{t("tree.forgetTerminal")}
</MenuItem>
</ContextMenu>
)}
{sessionCtx.menu && (
<SessionMenu
x={sessionCtx.menu.x}
@@ -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"]);
});
});
@@ -13,13 +13,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
// 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", () => ({
confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true),
@@ -32,6 +61,7 @@ vi.mock("../../../lib/api", () => ({
gitBranches: vi.fn(),
changes: vi.fn(),
gitAction: vi.fn(),
suggestCommitMessage: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
@@ -90,14 +120,20 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
};
}
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
}
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByText(path).closest("li")!).getByRole("button", {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
name: "Discard this file's changes",
});
}
beforeEach(() => {
window.localStorage.removeItem("agenthub.changes.neverStage.7");
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -113,6 +149,7 @@ beforeEach(() => {
author: "t",
date: "2026-09-22T01:00:00Z",
refs: ["HEAD -> feat/thing"],
parents: [],
},
],
});
@@ -140,8 +177,8 @@ beforeEach(() => {
});
});
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
/** The panel as the Workspace renders it: Files and Changes are its own mode
* buttons now, no activity-bar tab around it. */
function Tabbed({
laneId,
onDirtyChange,
@@ -149,18 +186,7 @@ function Tabbed({
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
}
describe("LaneChangesPanel", () => {
@@ -169,7 +195,7 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.queryByText(/new line/)).toBeNull();
});
@@ -194,7 +220,7 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
@@ -203,6 +229,7 @@ describe("LaneChangesPanel", () => {
context: 3,
})
);
await toDiff(user);
// 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.
const view = await screen.findByTestId("lane-diff-view");
@@ -258,6 +285,65 @@ describe("LaneChangesPanel", () => {
});
});
it("keeps a never-stage file out of Stage all, without telling git", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
await user.click(keep!); // the first row: server/lib/git-ops.js
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
"server/lib/git-ops.js",
]);
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
// And back: the row's button now offers the way out.
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
});
it("draws a never-stage file orange in the Files tree", async () => {
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
],
truncated: false,
});
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
expect(await row("notes.txt")).toHaveClass("text-kept");
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
expect(await row("a.txt")).toHaveClass("text-status-warning");
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
// `server/lib` holds nothing but `lib`: one compacted row.
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
@@ -341,16 +427,26 @@ describe("LaneChangesPanel", () => {
});
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
@@ -374,12 +470,12 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
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"));
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"));
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 () => {
@@ -408,7 +504,7 @@ describe("LaneChangesPanel", () => {
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({
available: true,
path: "",
@@ -433,29 +529,29 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts"));
// Opening a file only shows it: no editor until the text itself is clicked.
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);
// Opening a file is editing it, diffed against its HEAD text.
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
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-cancel")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel asks before dropping an edit: "keep" keeps it…
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(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" drops it and goes back to reading.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
// …and "discard" reverts it, still editing.
await user.click(screen.getByTestId("lane-file-cancel"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
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({
available: true,
path: "server/lib/git-ops.js",
@@ -468,32 +564,41 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await screen.findByRole("button", { name: "Changes", pressed: true });
// 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({
available: true,
path: "server/lib/git-ops.js",
rev: "work",
binary: false,
truncated: false,
lines: ["copy me"],
lines: ["a"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view");
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();
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
@@ -517,7 +622,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByText("server/lib/git-ops.js");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
await right();
@@ -577,9 +682,9 @@ describe("LaneChangesPanel", () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js");
await screen.findByTitle("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
@@ -612,7 +717,7 @@ describe("LaneChangesPanel", () => {
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("opens a commit from history as a commit-range review", async () => {
@@ -621,6 +726,7 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-overview");
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 waitFor(() =>
@@ -766,7 +872,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor);
@@ -779,36 +884,9 @@ describe("LaneChangesPanel", () => {
"const whole = 2;",
"b".repeat(40)
);
// Back to reading, showing what was saved.
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
// Still editing, on what was saved: nothing left to save.
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
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 () => {
@@ -852,7 +930,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save"));
@@ -892,7 +969,8 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
@@ -903,7 +981,8 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an
@@ -933,8 +1012,8 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/);
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() =>
@@ -995,11 +1074,10 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
for (const name of ["Overview", "Review", "History"]) {
for (const name of ["Changes", "Files", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
@@ -1351,9 +1429,9 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js");
const before = screen.getByTitle("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with ONE request, not one per folder", async () => {
@@ -1418,7 +1496,7 @@ describe("LaneChangesPanel", () => {
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
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", () => {
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
// 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", () => {
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const add = rowOf(container, "x = 2");
const del = rowOf(container, "x = 1");
// A line added or removed whole: nothing in common with its pair.
const WHOLE = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ 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()");
expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger");
expect(ctx.className).not.toMatch(/status-(success|danger)/);
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", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "x = 2"));
const minus = marker(rowOf(container, "x = 1"));
const plus = marker(rowOf(container, "xyz"));
const minus = marker(rowOf(container, "abc"));
const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+");
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", () => {
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
);
expect(emph).toEqual(["1", "2"]);
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
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", () => {
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
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);
render(<Harness laneId={1} />);
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
});
it("drops the previous lane's facts the moment the lane changes", async () => {
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
render(<LaneGitRailCounts laneId={1} />);
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");
});
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);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
fireEvent.focus(counts);
expect(screen.queryByTestId("lane-git-files")).toBeNull();
await screen.findByTestId("lane-git");
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
});
it("draws the rail's counts vertical", async () => {
@@ -193,7 +193,9 @@ describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0);
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.slice(0, 4))).toBe(4);
expect(freeSlot(2, all)).toBe(0);
@@ -222,6 +224,23 @@ describe("WorkspaceHome", () => {
);
});
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, Router/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
@@ -1,8 +1,10 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* 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
* 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.
@@ -11,7 +13,8 @@
import { useTranslation } from "react-i18next";
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 { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
@@ -61,7 +64,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
}
export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const reset = async () => {
@@ -78,67 +81,99 @@ export function AppearanceSettings() {
};
return (
<div className="card p-5 space-y-5">
<SettingsRow
label={t("appearance.mode")}
control={
<SettingsSegmented
ariaLabel={t("appearance.mode")}
value={mode}
onChange={setMode}
options={MODES.map(({ mode: m, Icon }) => ({
value: m,
label: (
<>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</>
),
}))}
/>
}
/>
<div className="space-y-6">
<SettingsSection
id="language"
title={t("language.title")}
description={t("language.description")}
>
<SettingsRow
label={t("language.label")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: t("language.names.en") },
{ value: "vi" as const, label: t("language.names.vi") },
]}
/>
}
/>
</SettingsSection>
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow
label={t("appearance.mode")}
control={
<SettingsSegmented
ariaLabel={t("appearance.mode")}
value={mode}
onChange={setMode}
options={MODES.map(({ mode: m, Icon }) => ({
value: m,
label: (
<>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</>
),
}))}
/>
}
/>
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
</div>
))}
))}
<div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")}
</button>
</div>
<div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")}
</button>
</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>
);
}
@@ -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
* 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
@@ -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>
);
}
@@ -1,14 +1,17 @@
/**
* @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>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18n from "../../../i18n";
import { AppearanceSettings } from "../AppearanceSettings";
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
const root = document.documentElement;
describe("AppearanceSettings", () => {
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
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", () => {
render(<AppearanceSettings />);
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",
"inactivity": "Inactivity",
"status_duration": "Stuck agent",
"token_threshold": "Token threshold"
"token_threshold": "Token threshold",
"cost_threshold": "Cost threshold",
"budget": "Spend budget"
},
"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).",
"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.",
"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": {
"eventPattern": "Matches {{pattern}}",
"eventPatternCount": "≥{{count}} × {{pattern}} within {{window}} min",
"inactivity": "No session activity 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": {
"title": "Alert rules",
@@ -48,7 +54,13 @@
"agentStatus": "Agent status",
"minutes": "Minutes",
"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?",
"help": {
@@ -63,8 +75,19 @@
"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).",
"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",
"save": "Save rule",
"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.",
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire."
},
"periods": {
"day": "day",
"week": "week",
"month": "month"
},
"errors": {
"loadRules": "Could not load alert rules: {{error}}",
"loadAlerts": "Could not load alerts: {{error}}",
+55
View File
@@ -51,6 +51,61 @@
"last7Days": "Last 7 days",
"less": "Less",
"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": {
"title": "Query Explorer",
"subtitle": "Filter sessions, events and agents, then export what you see.",
"entity": "Data",
"entities": {
"sessions": "Sessions",
"events": "Events",
"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)",
"cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
"avgPerWeekday30d": "Average per weekday, last 30 days"
+5 -1
View File
@@ -68,7 +68,11 @@
"justNow": "just now",
"mAgo": "{{count}}m 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": {
"previous": "Previous",
+13 -3
View File
@@ -59,6 +59,10 @@
"changes.collapseFile": "Collapse {{path}}",
"changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged",
"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.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone",
@@ -82,7 +86,7 @@
"changes.loading": "Reading the working copy…",
"changes.mode.browse": "Files",
"changes.mode.history": "History",
"changes.mode.overview": "Overview",
"changes.mode.overview": "Changes",
"changes.mode.review": "Review",
"changes.noCommits": "No commits yet.",
"changes.noHunks": "No textual change.",
@@ -98,6 +102,10 @@
"changes.size.compact": "Narrow",
"changes.size.half": "Medium",
"changes.size.full": "Full screen",
"changes.fileCount_one": "{{count}} file",
"changes.fileCount_other": "{{count}} files",
"changes.stageFolder": "Stage every file in this folder",
"changes.unstageFolder": "Unstage every file in this folder",
"changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage all ({{count}})",
"changes.dropEdits": "Discard your unsaved edits to this file?",
@@ -108,6 +116,7 @@
"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.unstage": "Unstage this file",
"changes.changesView": "Changes",
"changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
@@ -234,8 +243,8 @@
"changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit",
"changes.save": "Save",
"changes.revertBlock": "Revert",
"changes.cancelEdit": "Cancel",
"changes.clickToEdit": "Click the text to edit",
"changes.preview": "Preview",
"changes.raw": "Raw",
"changes.editorLabel": "Edit {{path}}",
@@ -299,7 +308,6 @@
"menu.copyPath": "Copy path",
"menu.reset": "Reset worktree…",
"menu.remove": "Delete workspace…",
"panel.files": "Files",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
"replay.placeholder": "Message to continue this session…",
@@ -323,6 +331,8 @@
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
"tree.reopenTerminal": "Open a new terminal",
"tree.forgetTerminal": "Remove from list",
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
"tree.renameSession": "Rename session",
+21 -1
View File
@@ -67,10 +67,30 @@
"today": "Today {{cost}}",
"workspace": "Workspace {{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",
"query": "Query Explorer",
"openMenu": "Open menu",
"resizeSidebar": "Resize sidebar (double-click to reset)",
"closeMenu": "Close menu",
"sidebar": "Sidebar",
"now": "now"
+49
View File
@@ -54,6 +54,55 @@
"copyId": "Copy session ID",
"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",
"exportJson": "JSON",
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
+102 -11
View File
@@ -21,7 +21,7 @@
"import": "Import .codex-pet bundle…",
"browse": "Find more on codex-pets.net",
"settings": "Pet settings…",
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.",
"hiddenToast": "Pet hidden. Show it again in Settings → Appearance.",
"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.",
"deleteConfirm": "Delete pet",
@@ -303,6 +303,14 @@
"typeToConfirm": "Type “{{phrase}}” to confirm:",
"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": {
"title": "About",
"description": "Server runtime information.",
@@ -561,6 +569,11 @@
"deleteAndPurge": "Remove + delete data",
"removeKeep": "Remove (keep data)",
"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"
},
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
@@ -587,6 +600,8 @@
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
"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.",
"perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.",
"tier": {
@@ -651,7 +666,10 @@
"reset": "Reset to defaults",
"resetTitle": "Reset appearance?",
"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": {
"title": "Git credentials",
@@ -751,17 +769,9 @@
"cost": "Cost"
},
"section": {
"general": {
"title": "General",
"description": "The dashboard's display language."
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only."
},
"pet": {
"title": "Pet",
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
"description": "Language, colour mode and theme, and the corner pet."
},
"notifications": {
"title": "Notifications",
@@ -805,6 +815,14 @@
"about": {
"title": "About",
"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",
@@ -842,6 +860,79 @@
"enable": "Enable",
"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": {
"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.",
+33 -5
View File
@@ -7,20 +7,26 @@
"event_pattern": "Mẫu sự kiện",
"inactivity": "Không hoạt động",
"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": {
"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.",
"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": {
"eventPattern": "Khớp {{pattern}}",
"eventPatternCount": "≥{{count}} × {{pattern}} trong {{window}} 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",
"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": {
"title": "Quy tắc cảnh báo",
@@ -48,7 +54,13 @@
"agentStatus": "Trạng thái agent",
"minutes": "Phút",
"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?",
"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).",
"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.",
"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",
"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.",
@@ -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.",
"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": {
"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}}",
+55
View File
@@ -51,6 +51,61 @@
"last7Days": "7 ngày gần nhất",
"less": "Ít",
"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": {
"title": "Truy vấn dữ liệu",
"subtitle": "Lọc phiên, sự kiện và agent, rồi xuất đúng những gì đang thấy.",
"entity": "Dữ liệu",
"entities": {
"sessions": "Phiên",
"events": "Sự kiện",
"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)",
"cacheReadsSub": "+{{reads}} token đọc cache · tỉ lệ trúng {{pct}}%",
"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",
"mAgo": "{{count}} phút 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": {
"previous": "Trước",
+13 -3
View File
@@ -59,6 +59,10 @@
"changes.collapseFile": "Thu gọn {{path}}",
"changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã 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.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",
@@ -82,7 +86,7 @@
"changes.loading": "Đang đọc working copy…",
"changes.mode.browse": "Tệp",
"changes.mode.history": "Lịch sử",
"changes.mode.overview": "Tổng quan",
"changes.mode.overview": "Thay đổi",
"changes.mode.review": "Xem toàn bộ",
"changes.noCommits": "Chưa có commit nào.",
"changes.noHunks": "Không có thay đổi nội dung.",
@@ -98,6 +102,10 @@
"changes.size.compact": "Hẹp",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình",
"changes.fileCount_one": "{{count}} tệp",
"changes.fileCount_other": "{{count}} tệp",
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
"changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
@@ -108,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.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.changesView": "Thay đổi",
"changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
@@ -234,8 +243,8 @@
"changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa",
"changes.save": "Lưu",
"changes.revertBlock": "Hoàn tác",
"changes.cancelEdit": "Huỷ",
"changes.clickToEdit": "Bấm vào nội dung để sửa",
"changes.preview": "Xem trước",
"changes.raw": "Mã nguồn",
"changes.editorLabel": "Sửa {{path}}",
@@ -299,7 +308,6 @@
"menu.copyPath": "Sao chép đường dẫn",
"menu.reset": "Đặt lại worktree…",
"menu.remove": "Xóa workspace…",
"panel.files": "Tệp",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
"replay.placeholder": "Nhắn để tiếp tục session này…",
@@ -323,6 +331,8 @@
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
"tree.reopenTerminal": "Mở terminal mới",
"tree.forgetTerminal": "Xóa khỏi danh sách",
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
"tree.renameSession": "Đổi tên session",
+21 -1
View File
@@ -67,10 +67,30 @@
"today": "Hôm nay {{cost}}",
"workspace": "Workspace {{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",
"query": "Truy vấn",
"openMenu": "Mở menu",
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
"closeMenu": "Đóng menu",
"sidebar": "Thanh bên",
"now": "bây giờ"
+49
View File
@@ -53,6 +53,55 @@
"copyId": "Sao chép ID session",
"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",
"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.",
+102 -11
View File
@@ -21,7 +21,7 @@
"import": "Nhập bundle .codex-pet…",
"browse": "Tìm thêm trên codex-pets.net",
"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}}?",
"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",
@@ -303,6 +303,14 @@
"typeToConfirm": "Gõ “{{phrase}}” để xác nhận:",
"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": {
"title": "Giới thiệu",
"description": "Thông tin runtime của máy chủ.",
@@ -561,6 +569,11 @@
"deleteAndPurge": "Xóa + xóa dữ liệu",
"removeKeep": "Xóa (giữ dữ liệu)",
"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"
},
"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ộ).",
@@ -587,6 +600,8 @@
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
"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.",
"perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": {
@@ -651,7 +666,10 @@
"reset": "Khôi phục mặc định",
"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.",
"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": {
"title": "Git credentials",
@@ -751,17 +769,9 @@
"cost": "Chi phí"
},
"section": {
"general": {
"title": "Chung",
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này."
},
"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."
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
},
"notifications": {
"title": "Thông báo",
@@ -805,6 +815,14 @@
"about": {
"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."
},
"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",
@@ -842,6 +860,79 @@
"enable": "Bậ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": {
"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.",
+6
View File
@@ -105,6 +105,10 @@
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--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 {
@@ -154,6 +158,8 @@
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
--syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */
--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
+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 });
});
});
@@ -0,0 +1,51 @@
/**
* @file Tests for the Changes panel's folder tree: grouping by folder,
* compacting single-child chains, counts, ordering and the per-folder file list.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { changeTree, treeFiles } from "../changeTree";
const rows = [
"app2/Services/TwitterService.php",
"app2/Services/Redis/SlowAlertRedisService.php",
"app2/Enums/Redis/RedisSlowAlertKey.php",
"config/monitoring.php",
"config/app.php",
"README.md",
].map((path) => ({ path }));
describe("changeTree", () => {
const root = changeTree(rows);
it("puts root files at the top level and counts every file", () => {
expect(root.count).toBe(6);
expect(root.files.map((f) => f.name)).toEqual(["README.md"]);
expect(root.dirs.map((d) => d.name)).toEqual(["app2", "config"]);
});
it("compacts a folder chain with nothing else in it", () => {
const app2 = root.dirs[0]!;
expect(app2.count).toBe(3);
expect(app2.dirs.map((d) => [d.name, d.path])).toEqual([
["Enums/Redis", "app2/Enums/Redis"],
["Services", "app2/Services"],
]);
// Services holds a file, so it is not folded into Redis.
expect(app2.dirs[1]!.dirs[0]!.name).toBe("Redis");
});
it("sorts files by name and lists a folder's files", () => {
expect(root.dirs[1]!.files.map((f) => f.name)).toEqual(["app.php", "monitoring.php"]);
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()));
});
});
+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);
});
});
+187 -3
View File
@@ -387,9 +387,12 @@ import type {
AlertEvent,
AlertRule,
Analytics,
BudgetStatus,
RulePreview,
CodexHooksStatus,
AntigravityHooksStatus,
CostResult,
ToolQuota,
DashboardEvent,
Lane,
LaneChangeRange,
@@ -423,10 +426,17 @@ import type {
WorkflowRun,
WorkflowRunsResponse,
WorkflowRunDetail,
SessionSnapshotInfo,
SessionSnapshotPayload,
QueryPage,
ReportDefinition,
ReportInput,
ReportRun,
} from "./types";
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
import { applyRange, type DateRange } from "./dateRange";
// 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.
@@ -978,10 +988,15 @@ export const api = {
* day, the viewer's timezone offset is sent so the daily buckets align to
* 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.
*/
get: () => {
get: (opts: { tool?: string; range?: DateRange } = {}) => {
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.tool) qs.set("tool", opts.tool);
applyRange(qs, opts.range);
applyScope(qs);
return request<Analytics>(`/analytics?${qs.toString()}`);
},
@@ -1099,6 +1114,41 @@ export const api = {
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
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
* only on a click; agy runs it with no trust step. */
antigravityHooks: {
@@ -1255,6 +1305,8 @@ export const api = {
* "all" (which means "no filter"), keeping the default URL param-free.
*
* @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=`);
* the server skips the rest, so a page reading a few cards asks for those.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
@@ -1262,10 +1314,14 @@ export const api = {
*/
get: <K extends keyof WorkflowData = keyof WorkflowData>(
status?: string,
tool?: string,
range?: DateRange,
sections?: readonly K[]
) => {
const qs = new URLSearchParams();
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(","));
const q = qs.toString();
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
@@ -1380,18 +1436,26 @@ export const api = {
*
* @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 /
// stats / analytics endpoints — otherwise switching the Data scope selector
// 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()) });
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
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);
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
* the session's start date.
@@ -2060,6 +2124,24 @@ export const api = {
{ 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`). */
pets: {
list: () => request<PetList>("/pets"),
@@ -2186,6 +2268,9 @@ export const api = {
if (opts.limit) qs.set("limit", String(opts.limit));
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
forgetHistory: (id: string) =>
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
@@ -2320,9 +2405,65 @@ export const api = {
*/
remove: (id: string) =>
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 ─────────────────────────────
/** Outbound webhook target CRUD, provider metadata, test sends, and the
* per-target delivery log. */
@@ -2757,6 +2898,12 @@ export const api = {
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
/**
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
* written by the user's `claude` CLI (no tools, nothing persisted).
*/
suggestCommitMessage: (id: GitTarget) =>
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
@@ -3286,6 +3433,8 @@ export interface AiRouter {
hasKey: boolean;
model: string;
models: AiRouterModels;
/** Claude model id the router's own model names behave as; "" when unset. */
behavesAs?: string;
savedAt?: string;
}
@@ -3318,6 +3467,8 @@ export interface AiRouterInput {
model: string;
/** Any tier left out is filled from `model`. */
models?: Partial<AiRouterModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
}
/** One markdown-based config artifact (skill/agent/command/output-style),
@@ -4153,6 +4304,10 @@ export interface RemoteSource {
identity_file: string | null;
/** Optional remote CLAUDE_HOME (default `~/.claude`). */
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. */
enabled: boolean;
/** Last known sync state. */
@@ -4169,6 +4324,9 @@ export interface RemoteSource {
errors?: number;
sessions_seen?: number;
sessions_tagged?: number;
/** Codex journals read / sessions written, when Codex is pulled. */
codex_files?: number;
codex_sessions?: number;
} | null;
/** Live number of sessions currently attributed to this source. */
session_count?: number;
@@ -4183,9 +4341,35 @@ export interface RemoteSourceInput {
ssh_port?: number | null;
identity_file?: string | null;
remote_home?: string | null;
sync_codex?: boolean;
remote_codex_home?: string | null;
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). */
export interface RemoteSourceTestResult {
ok: boolean;
+40
View File
@@ -0,0 +1,40 @@
/**
* @file Spend pace from the daily cost series (`daily_costs` of GET
* /api/pricing/cost, already in local dates): the average cost per day over
* the last 7 days, the month to date, and a month-end forecast that carries
* that pace over the days left in the month.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface SpendPace {
/** Average cost per day over the 7 days ending today (today included). */
perDay7: number;
/** Spend since the 1st of this month. */
monthToDate: number;
/** monthToDate + perDay7 × the days left after today. */
forecastMonth: number;
}
/**
* @param daily `{date: "YYYY-MM-DD", cost}` per local day, any order.
* @param today Today's local date, `YYYY-MM-DD`.
*/
export function spendPace(daily: Array<{ date: string; cost: number }>, today: string): SpendPace {
const [y, m, d] = today.split("-").map(Number) as [number, number, number];
const day = (offset: number) => {
const t = new Date(Date.UTC(y, m - 1, d + offset));
return t.toISOString().slice(0, 10);
};
const weekStart = day(-6);
const monthStart = today.slice(0, 8) + "01";
let week = 0;
let mtd = 0;
for (const r of daily) {
if (r.date > today) continue;
if (r.date >= weekStart) week += r.cost;
if (r.date >= monthStart) mtd += r.cost;
}
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
const perDay7 = week / 7;
return { perDay7, monthToDate: mtd, forecastMonth: mtd + perDay7 * (daysInMonth - d) };
}
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Folds the Changes panel's flat list of changed files into a folder
* tree, JetBrains' commit-view shape: folders first, then files, each sorted
* by name, and a chain of folders that hold nothing but one sub-folder drawn
* as ONE row (`src/lib`), so a deep path does not cost a row per level.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface ChangeTreeDir<T> {
/** The row's label — one segment, or several joined by `/` when compacted. */
name: string;
/** The folder's repo-relative path ("" for the root). */
path: string;
dirs: ChangeTreeDir<T>[];
files: { name: string; file: T }[];
/** Every file anywhere below. */
count: number;
}
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
for (const file of rows) {
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
const parts = file.path.replace(/\/$/, "").split("/");
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
let dir = root;
dir.count++;
for (const seg of parts.slice(0, -1)) {
const path = dir.path ? `${dir.path}/${seg}` : seg;
let next = dir.dirs.find((d) => d.path === path);
if (!next) dir.dirs.push((next = { name: seg, path, dirs: [], files: [], count: 0 }));
next.count++;
dir = next;
}
dir.files.push({ name: parts[parts.length - 1] ?? file.path, file });
}
const finish = (d: ChangeTreeDir<T>): ChangeTreeDir<T> => {
// Never the root: its children are the group's top level.
let only: ChangeTreeDir<T> | undefined;
while (d.path && d.files.length === 0 && d.dirs.length === 1 && (only = d.dirs[0])) {
d = { ...only, name: `${d.name}/${only.name}` };
}
d.dirs = d.dirs.map(finish).sort((a, b) => a.name.localeCompare(b.name));
d.files.sort((a, b) => a.name.localeCompare(b.name));
return d;
};
return finish(root);
}
/** Every file under a folder, for its stage/unstage-all button. */
export function treeFiles<T>(d: ChangeTreeDir<T>): T[] {
return [...d.files.map((f) => f.file), ...d.dirs.flatMap(treeFiles)];
}
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Lays out the Changes panel's History as a commit graph (the JetBrains /
* `git log --graph` shape): each commit gets a column, and each row lists the
* line segments drawn through it — a lane passing by, the lanes converging on
* the commit from above, and the lines leaving it for its parents below.
* Expects the commits in an order where no parent precedes a child
* (`git log --date-order`); a parent outside the list leaves its lane running
* off the bottom.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** One line in a row, in column units; y runs 0 (top) → 0.5 (node) → 1 (bottom). */
export interface GraphSeg {
x1: number;
y1: number;
x2: number;
y2: number;
color: number;
}
export interface GraphRow {
col: number;
color: number;
segs: GraphSeg[];
}
export function commitGraph(commits: { sha: string; parents: string[] }[]): {
rows: GraphRow[];
width: number;
} {
// lanes[j] = the sha the lane in column j is waiting for; colors[j] its hue.
const lanes: (string | null)[] = [];
const colors: number[] = [];
const hue = (j: number) => colors[j] ?? 0;
let nextColor = 0;
let width = 1;
const open = () => {
const free = lanes.indexOf(null);
const j = free < 0 ? lanes.length : free;
colors[j] = nextColor++;
return j;
};
const rows = commits.map((c) => {
const segs: GraphSeg[] = [];
let col = lanes.indexOf(c.sha);
// A tip nothing above points at: a lane of its own, no line from the top.
if (col < 0) {
col = open();
lanes[col] = null;
}
lanes.forEach((s, j) => {
if (s === null) return;
segs.push(
s === c.sha
? { x1: j, y1: 0, x2: col, y2: 0.5, color: hue(j) }
: { x1: j, y1: 0, x2: j, y2: 1, color: hue(j) }
);
});
const color = hue(col);
lanes.forEach((s, j) => {
if (s === c.sha) lanes[j] = null;
});
c.parents.forEach((p, i) => {
let j = i === 0 ? col : lanes.indexOf(p);
if (j < 0) j = open();
lanes[j] = p;
segs.push({ x1: col, y1: 0.5, x2: j, y2: 1, color: hue(j) });
});
while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();
width = Math.max(width, lanes.length, col + 1);
return { col, color, segs };
});
return { rows, width };
}
+64
View File
@@ -0,0 +1,64 @@
/**
* @file dateRange — the Analytics date range: the preset ids kept in `?range=`
* (today / this week from Monday / this month from the 1st / 7d / 30d / 90d /
* all / custom), and `rangeDates`, which turns a preset (or
* a custom from/to) into the local `YYYY-MM-DD` bounds sent as `from`/`to`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const RANGE_PRESETS = [
"today",
"week",
"month",
"7d",
"30d",
"90d",
"all",
"custom",
] as const;
export type RangePreset = (typeof RANGE_PRESETS)[number];
/** Local, inclusive bounds; an absent end is open. */
export interface DateRange {
from?: string;
to?: string;
}
/** A `?range=` value, or `all` for anything else. */
export function parsePreset(v: string | null): RangePreset {
return (RANGE_PRESETS as readonly string[]).includes(v ?? "") ? (v as RangePreset) : "all";
}
/** `d` as a local `YYYY-MM-DD`. */
export function localDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
/** The bounds of a preset: `7d` = today and the 6 days before it; `week`
* starts on Monday, `month` on the 1st, both ending today. */
export function rangeDates(
preset: RangePreset,
custom: DateRange = {},
today: Date = new Date()
): DateRange {
if (preset === "all") return {};
if (preset === "custom") {
return { from: custom.from || undefined, to: custom.to || undefined };
}
const start = new Date(today);
if (preset === "week") start.setDate(today.getDate() - ((today.getDay() + 6) % 7));
else if (preset === "month") start.setDate(1);
else if (preset !== "today") start.setDate(today.getDate() - (parseInt(preset, 10) - 1));
return { from: localDate(start), to: localDate(today) };
}
/** Set `from`/`to` (and `tz_offset`, which reads them) on a query string. */
export function applyRange(qs: URLSearchParams, range?: DateRange): URLSearchParams {
if (range?.from) qs.set("from", range.from);
if (range?.to) qs.set("to", range.to);
if ((range?.from || range?.to) && !qs.has("tz_offset")) {
qs.set("tz_offset", String(new Date().getTimezoneOffset()));
}
return qs;
}
+8 -5
View File
@@ -337,23 +337,26 @@ export function formatMs(ms: number): string {
* Formats how long ago an ISO/SQLite timestamp was, as a translated relative
* string ("just now", "5m ago", "3h ago", "2d ago") using {@link i18n}.
* @param iso A past timestamp (see {@link parseDate}).
* @param short Drop the "ago" ("now", "5m") — the `common:time.*Short` keys.
* @returns A localized relative-time phrase; i18next handles pluralization via `count`.
* @remarks Thresholds cascade seconds -> minutes -> hours -> days (days is the largest
* bucket, so a 40-day-old event reads "40d ago"). Under a minute collapses to the
* "just now" key. Uses translated keys, not `Intl.RelativeTimeFormat`, so the exact
* wording is controlled by the `common:time.*` translation resources.
*/
export function timeAgo(iso: string): string {
export function timeAgo(iso: string, short = false): string {
const ms = Date.now() - parseDate(iso).getTime();
if (Number.isNaN(ms)) return "-";
// `short` drops the "ago" ("17m"), for a narrow column like the sidebar's.
const k = short ? "Short" : "";
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return i18n.t("common:time.justNow");
if (seconds < 60) return i18n.t(`common:time.justNow${k}`);
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return i18n.t("common:time.mAgo", { count: minutes });
if (minutes < 60) return i18n.t(`common:time.mAgo${k}`, { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return i18n.t("common:time.hAgo", { count: hours });
if (hours < 24) return i18n.t(`common:time.hAgo${k}`, { count: hours });
const days = Math.floor(hours / 24);
return i18n.t("common:time.dAgo", { count: days });
return i18n.t(`common:time.dAgo${k}`, { count: days });
}
// ===========================================================================
+13
View File
@@ -3,6 +3,8 @@
* @description Keyboard helpers shared by every single-line submit field.
* `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese
* Telex, CJK): without it a rename or commit message is committed half-typed.
* `copyOnCtrlShiftC` makes Ctrl+Shift+C a copy, as in a Linux terminal, instead
* of the browser's inspect-element shortcut (App.tsx installs it, capture phase).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,3 +15,14 @@ export function isSubmitEnter(e: ReactKeyboardEvent | KeyboardEvent): boolean {
const native = "nativeEvent" in e ? e.nativeEvent : e;
return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229;
}
/**
* Ctrl+Shift+C copies the selection — a field's, the page's or a terminal's
* (xterm answers the `copy` event) — instead of opening DevTools. F12 and
* Ctrl+Shift+I still open them. With nothing selected it does nothing.
*/
export function copyOnCtrlShiftC(e: KeyboardEvent): void {
if (e.code !== "KeyC" || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
e.preventDefault();
document.execCommand("copy");
}
+124
View File
@@ -0,0 +1,124 @@
/**
* @file replay.ts
* @description Pure derivations behind the session Replay tab: events in
* playback order, the lifecycle markers on its mini-map, and the state a
* session was in at a cursor (status, tool in flight, tool counts).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { AgentStatus, DashboardEvent } from "./types";
import { statusFromEventType } from "./event-grouping";
export const REPLAY_SPEEDS = [0.5, 1, 2, 5] as const;
/** One event per this many ms at 1× — playback walks the sequence, not the
* wall clock, so an hour-long pause does not stall the replay. */
export const REPLAY_STEP_MS = 500;
/** Event types drawn on the mini-map, in legend order. */
export const LIFECYCLE_TYPES = [
"SessionStart",
"UserPromptSubmit",
"Compaction",
"SubagentStop",
"APIError",
"Stop",
] as const;
/** Oldest first; `id` breaks timestamp ties. */
export function chronological(events: DashboardEvent[]): DashboardEvent[] {
return [...events].sort(
(a, b) => a.created_at.localeCompare(b.created_at) || (a.id ?? 0) - (b.id ?? 0)
);
}
export interface ReplayMarker {
index: number;
type: string;
/** Position on the mini-map, 0–100, matching the slider's scale. */
pct: number;
}
export function lifecycleMarkers(events: DashboardEvent[]): ReplayMarker[] {
const kinds = new Set<string>(LIFECYCLE_TYPES);
const last = Math.max(1, events.length - 1);
const out: ReplayMarker[] = [];
events.forEach((e, index) => {
if (kinds.has(e.event_type)) out.push({ index, type: e.event_type, pct: (index / last) * 100 });
});
return out;
}
export interface ReplayState {
status: AgentStatus | null;
/** The newest tool started and not yet finished at the cursor. */
currentTool: string | null;
/** Tool calls (PreToolUse) so far, most used first. */
toolCounts: { tool: string; count: number }[];
prompts: number;
errors: number;
compactions: number;
/** From the first event to the cursor's. */
elapsedMs: number;
}
/** The session as it stood after `events[cursor]` (events in playback order). */
export function replayState(events: DashboardEvent[], cursor: number): ReplayState {
const end = Math.min(cursor, events.length - 1);
const open: { agent: string; tool: string }[] = [];
const counts = new Map<string, number>();
let prompts = 0;
let errors = 0;
let compactions = 0;
for (let i = 0; i <= end; i++) {
const e = events[i]!;
const agent = e.agent_id ?? "";
switch (e.event_type) {
case "PreToolUse":
if (e.tool_name) {
open.push({ agent, tool: e.tool_name });
counts.set(e.tool_name, (counts.get(e.tool_name) ?? 0) + 1);
}
break;
case "PostToolUse":
case "PostToolUseFailure": {
for (let j = open.length - 1; j >= 0; j--) {
if (open[j]!.agent === agent && open[j]!.tool === e.tool_name) {
open.splice(j, 1);
break;
}
}
if (e.event_type === "PostToolUseFailure") errors++;
break;
}
case "Stop":
case "SubagentStop":
case "SessionEnd":
// A turn that ended leaves nothing of that agent in flight.
for (let j = open.length - 1; j >= 0; j--) if (open[j]!.agent === agent) open.splice(j, 1);
break;
case "UserPromptSubmit":
prompts++;
break;
case "APIError":
errors++;
break;
case "Compaction":
compactions++;
break;
}
}
const first = events[0];
const at = end >= 0 ? events[end] : undefined;
return {
status: at ? statusFromEventType(at.event_type) : null,
currentTool: open[open.length - 1]?.tool ?? null,
toolCounts: [...counts]
.map(([tool, count]) => ({ tool, count }))
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)),
prompts,
errors,
compactions,
elapsedMs:
first && at ? Math.max(0, Date.parse(at.created_at) - Date.parse(first.created_at)) : 0,
};
}
+44 -5
View File
@@ -34,10 +34,25 @@ export const SETTINGS_INDEX: {
to?: string;
}[] = [
{
section: "general",
section: "appearance",
titleKey: "language.title",
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{
section: "data",
titleKey: "pushIngest.title",
descKey: "pushIngest.description",
keywords: ["token", "push", "remote", "batch"],
to: "#push-ingest",
},
{
section: "data",
titleKey: "privacy.title",
descKey: "privacy.description",
keywords: ["redact", "secrets", "ignore", "folder", "cwd"],
to: "#privacy",
},
{
section: "data",
titleKey: "lanesSection.title",
@@ -90,14 +105,38 @@ export const SETTINGS_INDEX: {
keywords: ["version", "server", "database", "uptime"],
},
{
section: "pet",
section: "about",
titleKey: "autostart.label",
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
to: "#autostart",
},
{
section: "appearance",
titleKey: "sagi.title",
descKey: "sagi.description",
keywords: ["sagi", "mascot", "pet"],
to: "#pet",
},
{ section: "appearance", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc", to: "#pet" },
{ section: "appearance", titleKey: "sagi.pet.size", to: "#pet" },
{
section: "appearance",
titleKey: "sagi.pet.choose",
keywords: ["codex-pets", "upload", "import"],
to: "#pet",
},
{
section: "snapshots",
titleKey: "snapshots.title",
descKey: "snapshots.description",
keywords: ["share", "freeze", "link", "redact"],
},
{
section: "reports",
titleKey: "reports.title",
descKey: "reports.description",
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
},
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
{ section: "pet", titleKey: "sagi.pet.size" },
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
+172 -3
View File
@@ -731,8 +731,8 @@ export interface Session {
source?: string;
/** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks) or `"antigravity"` (agy's conversation files and
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
* journals and hooks), `"antigravity"` (agy's conversation files and
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started.
@@ -1204,6 +1204,28 @@ export interface CostResult {
feature_costs?: CostFeatureCosts;
/** Present only when at least one model had usage but no pricing rule. */
unpriced_models?: UnpricedModel[];
/** Cost per AI tool (`sessions.tool`), most expensive first. Only
* GET /api/pricing/cost sends it, not the per-session route. */
by_tool?: Array<{ tool: string; cost: number }>;
}
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
export interface ToolQuota {
id: "claude" | "codex";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100. */
windows: Array<{ label: string; usedPercent: number | null; resetsAt: string | null }>;
/** Why there is no figure, e.g. `ELIVEEXPIRED` (Claude's token needs a CLI refresh). */
error?: string;
/** False for a saved Claude auth profile that is not the live login. */
active?: boolean;
/** The saved profile's label, when the account has one. */
label?: string | null;
/** The signed-in account's email, when the tool's login names one. */
account?: string | null;
/** When the stored access token expires (ISO). */
expiresAt?: string | null;
/** When the figure was read, if not just now (Codex's journal, an expired profile). */
asOf?: string;
}
// ───── Import progress ─────
@@ -1514,7 +1536,13 @@ export interface CodexConfigChangedPayload {
/** Kind of condition an {@link AlertRule} evaluates. "event_pattern" and
* "token_threshold" are checked on every hook ingest; "inactivity" and
* "status_duration" are checked on a periodic server-side sweep. */
export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" | "token_threshold";
export type AlertRuleType =
| "event_pattern"
| "inactivity"
| "status_duration"
| "token_threshold"
| "cost_threshold"
| "budget";
/**
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
@@ -1525,6 +1553,9 @@ export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" |
* - inactivity: `minutes` of session silence before firing.
* - status_duration: `status` held continuously for `minutes`.
* - token_threshold: cumulative `total_tokens` for a session.
* - cost_threshold: a session's cost reaching `usd`.
* - budget: spend per `period` against `limit_usd`, firing each of
* `thresholds` (percent) once per period, optionally for one `tool`/`lane`.
*/
export interface AlertRuleConfig {
/** event_pattern: exact `DashboardEvent.event_type` to match. */
@@ -1546,6 +1577,55 @@ export interface AlertRuleConfig {
status?: "working" | "waiting";
/** token_threshold: cumulative token count that triggers the alert. */
total_tokens?: number;
/** cost_threshold: a session's cost in USD that triggers the alert. */
usd?: number;
/** budget: the period spend is measured over (local time). */
period?: "day" | "week" | "month";
/** budget: the period's limit in USD. */
limit_usd?: number;
/** budget: percents of the limit that fire, each once per period. */
thresholds?: number[];
/** budget: only this AI tool's sessions. */
tool?: string;
/** budget: only the sessions this workspace owns. */
lane?: number;
/** budget: the browser's getTimezoneOffset() when the rule was saved. */
tz_offset?: number;
}
/** One budget from GET /api/budgets/status: its rule plus current-period spend. */
export interface BudgetStatus {
id: string;
name: string;
config: AlertRuleConfig;
/** Local dates (inclusive) of the current period. */
period_start: string;
period_end: string;
spend: number;
/** The spend at the current pace, carried to the period's end. */
projected: number;
/** Spend as a percent of `limit_usd`. */
pct: number;
/** Thresholds already reached this period, lowest first. */
crossed: number[];
}
/** POST /api/alerts/rules/preview — what a rule would have fired, read-only. */
export interface RulePreview {
/** False for a type history cannot replay (status_duration). */
supported: boolean;
window_days: number;
fires: number;
sessions: number;
samples: Array<{ session_id: string; at: string } & Record<string, unknown>>;
/** budget only: the current period and the five before it. */
periods?: Array<{
period_start: string;
period_end: string;
spend: number;
pct: number;
crossed: number[];
}>;
}
/** A user-defined alert rule from GET/POST/PATCH /api/alerts/rules. Couples a
@@ -1908,6 +1988,89 @@ export interface SessionStats {
};
}
/** A row of `GET /api/snapshots` — a frozen session, listed without its payload. */
export interface SessionSnapshotInfo {
id: number;
/** The unguessable read token: `/snapshot/<token>`. */
token: string;
session_id: string;
created_at: string;
session_name?: string | null;
/** File paths were replaced at capture. */
redacted?: boolean;
event_count?: number;
/** Size of the stored payload, bytes. */
bytes?: number;
}
export type QueryEntity = "sessions" | "events" | "agents";
/** `GET /api/query` — one page of an entity's filtered rows. */
export interface QueryPage {
entity: QueryEntity;
/** Column names, in order (also the export's header). */
columns: string[];
/** The columns `sort` accepts. */
sortable: string[];
/** The values `status` accepts (none for events). */
statuses: string[];
rows: Record<string, string | number | null>[];
total: number;
limit: number;
offset: number;
/** An export holds at most this many rows. */
export_cap: number;
}
/** A scheduled report definition (`/api/reports`). */
export interface ReportInput {
name: string;
/** Full local days before the run day. */
window_days: number;
frequency: "daily" | "weekly";
/** Local hour, 0–23. */
hour: number;
/** 0 = Sunday; weekly only. */
weekday: number;
/** getTimezoneOffset() of the browser that saved it. */
tz_offset: number;
formats: ("html" | "json")[];
enabled: boolean;
}
export interface ReportRun {
id: number;
report_id: number;
created_at: string;
trigger: "schedule" | "manual";
status: "ok" | "error";
window_from: string | null;
window_to: string | null;
/** The formats written. */
files: string[];
error: string | null;
}
export interface ReportDefinition extends ReportInput {
id: number;
created_at: string;
last_slot: string | null;
last_run?: ReportRun | null;
}
/** What `POST /api/snapshots` froze: the session as it stood at `captured_at`. */
export interface SessionSnapshotPayload {
version: number;
captured_at: string;
redacted: { file_paths: boolean };
session: Session;
agents: Agent[];
/** Oldest first. */
events: DashboardEvent[];
stats: SessionStats;
cost: CostResult;
}
// ───── Workflow intelligence (events-derived) ─────
// The analytics panels on the Workflows page. Every type below is COMPUTED by
// the server from `DashboardEvent`/`Agent` rows (server/routes/workflows.js) -
@@ -2716,6 +2879,8 @@ export interface LaneCommit {
date: string;
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
refs: string[];
/** Parent shas, first parent first — the history's commit graph. */
parents: string[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
@@ -2743,6 +2908,8 @@ export interface LaneTreeEntry {
status?: string;
/** A folder with a changed or untracked file anywhere under it. */
changed?: true;
/** Ignored by git (`.gitignore`) — drawn orange in the Files tree. */
ignored?: true;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
@@ -2795,6 +2962,8 @@ export type LaneChanges =
base: string | null;
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
baseRef: string | null;
/** `range: "branch"`: the merge base the diff is taken from (full sha). */
mergeBase?: string | null;
/** The commit being shown, for `range: "commit"`; its full sha. */
sha: string | null;
/** Lines of unchanged context each hunk carries. */
+14 -1
View File
@@ -39,8 +39,9 @@ import { useDialog } from "../hooks/useDialog";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
apiKey: string;
behavesAs: string;
models: Partial<AiRouterModels>;
};
@@ -50,6 +51,7 @@ const emptyForm = (): FormState => ({
apiKey: "",
model: "",
models: {},
behavesAs: "",
});
export function AiRouterConfig() {
@@ -121,6 +123,7 @@ export function AiRouterConfig() {
apiKey: "",
model: item.model,
models: { ...item.models },
behavesAs: item.behavesAs ?? "",
});
setFormError(null);
setOpen(true);
@@ -175,6 +178,7 @@ export function AiRouterConfig() {
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
models,
behavesAs: form.behavesAs.trim(),
};
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
@@ -370,6 +374,15 @@ export function AiRouterConfig() {
mono
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<Field
id="router-behaves-as"
label={t("aiRouter.fieldBehavesAs")}
hint={t("aiRouter.fieldBehavesAsHint")}
value={form.behavesAs}
mono
placeholder="claude-opus-5-5"
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
/>
<details className="rounded-lg border border-border bg-surface-2 p-2">
<summary className="cursor-pointer text-xs text-fg-secondary">
+186 -6
View File
@@ -107,9 +107,15 @@ import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { Tip } from "../components/Tip";
import { Skeleton } from "../components/Skeleton";
import { Select } from "../components/Select";
import { AiToolIcon } from "../components/AiToolIcon";
import { useAiTools } from "../hooks/useAiTools";
import { spendPace } from "../lib/burnRate";
import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange";
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
import { statusFromEventType } from "../lib/event-grouping";
import type {
BudgetStatus,
Stats,
Agent,
DashboardEvent,
@@ -700,6 +706,7 @@ function AnalyticsTab() {
const { t, i18n } = useTranslation("analytics");
const [data, setData] = useState<AnalyticsData | null>(null);
const [costData, setCostData] = useState<CostResult | null>(null);
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
@@ -723,6 +730,28 @@ function AnalyticsTab() {
);
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
// One AI tool's sessions only ("" = every tool); a change re-runs `load`.
const [tool, setTool] = useState("");
const aiTools = useAiTools();
// The date range, kept in the URL (`?range=`, plus `from`/`to` when custom).
// Headline numbers, cost/token cards and by-model/by-tool follow it; the
// 52-week and 30-day charts keep their own windows.
const preset = parsePreset(params.get("range"));
const customFrom = params.get("from") ?? "";
const customTo = params.get("to") ?? "";
const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo });
const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]);
const setRangeParam = (patch: Record<string, string>) =>
setParams(
(p) => {
for (const [k, v] of Object.entries(patch)) {
if (v) p.set(k, v);
else p.delete(k);
}
return p;
},
{ replace: true }
);
const [loadError, setLoadError] = useState<string | null>(null);
// Responses can land out of order (a slow poll after a fast refresh); only
@@ -731,14 +760,16 @@ function AnalyticsTab() {
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const [result, cost] = await Promise.all([
api.analytics.get(),
api.pricing.totalCost().catch(() => null),
const [result, cost, budgetRes] = await Promise.all([
api.analytics.get({ tool, range }),
api.pricing.totalCost({ tool, range }).catch(() => null),
api.budgets.status().catch(() => null),
]);
if (seq !== loadSeq.current) return;
setData(result);
setCostData(cost);
setLoadError(null);
if (budgetRes) setBudgets(budgetRes.budgets);
setLastUpdate(new Date());
} catch (err) {
if (seq === loadSeq.current)
@@ -746,7 +777,7 @@ function AnalyticsTab() {
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope]);
}, [scope, tool, range]);
useEffect(() => {
load();
@@ -861,6 +892,7 @@ function AnalyticsTab() {
dailyCostLast30[0] ?? { date: "", cost: 0 }
);
const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0);
const pace = spendPace(dailyCostsLocal, localDateStr(today));
const costBreakdown = [...(costData?.breakdown ?? [])]
.filter((b) => b.cost > 0)
.sort((a, b) => b.cost - a.cost);
@@ -985,6 +1017,53 @@ function AnalyticsTab() {
{lastUpdate.toLocaleTimeString()}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-40">
<Select
value={preset}
onChange={(v) =>
setRangeParam(
v === "custom"
? { range: v, from: rangeFrom ?? "", to: rangeTo ?? "" }
: { range: v === "all" ? "" : v, from: "", to: "" }
)
}
label={t("range")}
options={RANGE_PRESETS.map((v) => ({ value: v, label: t(`rangePreset.${v}`) }))}
/>
</div>
{preset === "custom" && (
<>
<input
type="date"
className="input text-sm"
value={customFrom}
max={customTo || undefined}
onChange={(e) => setRangeParam({ from: e.target.value })}
aria-label={t("rangeFrom")}
title={t("rangeFrom")}
/>
<input
type="date"
className="input text-sm"
value={customTo}
min={customFrom || undefined}
onChange={(e) => setRangeParam({ to: e.target.value })}
aria-label={t("rangeTo")}
title={t("rangeTo")}
/>
</>
)}
<div className="w-44">
<Select
value={tool}
onChange={setTool}
label={t("toolFilter")}
options={[
{ value: "", label: t("allTools") },
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
]}
/>
</div>
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common:refresh")}
@@ -1353,6 +1432,107 @@ function AnalyticsTab() {
)}
</div>
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
{ label: t("monthToDate"), value: pace.monthToDate },
{
label: t("forecastMonth"),
value: pace.forecastMonth,
hint: t("forecastHint"),
},
].map(({ label, value, hint }) => (
<div
key={label}
className="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span className="text-xs text-fg-secondary" title={hint}>
{label}
</span>
<span className="text-sm font-mono font-medium text-status-success">
<Tip raw={fmtCostFull(value)}>{fmtCost(value)}</Tip>
</span>
</div>
))}
</div>
</div>
{/* Budgets (budget alert rules) */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("budgets")}</h3>
{budgets.length === 0 ? (
<p className="text-sm text-fg-muted">
{t("noBudgets")}{" "}
<Link to="/settings/notifications" className="text-accent hover:underline">
{t("addBudget")}
</Link>
</p>
) : (
<div className="space-y-4">
{budgets.map((b) => {
const over = b.pct >= 100;
const warn = b.crossed.length > 0;
return (
<div key={b.id} className="space-y-1.5">
<div className="flex justify-between text-xs gap-2">
<span className="text-fg-secondary truncate" title={b.name}>
{b.name}
</span>
<span className="font-mono text-fg-secondary flex-shrink-0">
{fmtCost(b.spend)} / {fmtCost(b.config.limit_usd ?? 0)}
</span>
</div>
<div className="bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full ${over ? "bg-status-danger" : warn ? "bg-status-warning" : "bg-status-success"}`}
style={{ width: `${Math.min(100, Math.max(2, b.pct))}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-fg-muted">
<span>
{b.period_start} → {b.period_end}
</span>
<span title={t("projectedHint")}>
{t("projected")} {fmtCost(b.projected)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Cost by AI tool */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByTool")}</h3>
{(costData?.by_tool?.length ?? 0) > 0 ? (
<div className="space-y-3">
{(costData?.by_tool ?? []).map((b) => (
<div key={b.tool} className="flex items-center gap-2">
<AiToolIcon
tool={{ id: b.tool, label: aiTools.label(b.tool) }}
className="h-5 w-5"
/>
<div className="flex-1 min-w-0">
<CostBarRow
label={aiTools.label(b.tool)}
cost={b.cost}
max={Math.max(costData?.by_tool?.[0]?.cost ?? 0, 0.0001)}
color="bg-accent"
/>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
)}
</div>
{/* Cost by weekday */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">
@@ -1471,7 +1651,7 @@ function AnalyticsTab() {
{/* The former Workflows page, folded into this sub-tab. */}
<RouteErrorBoundary>
<Suspense fallback={<AnalyticsChartsSkeleton />}>
<Workflows embedded />
<Workflows embedded tool={tool || undefined} range={range} />
</Suspense>
</RouteErrorBoundary>
</div>
@@ -1605,7 +1785,7 @@ function SystemHealthTab() {
try {
const [infoRes, workflowRes] = await Promise.all([
api.settings.info(),
api.workflows.get(undefined, HEALTH_SECTIONS),
api.workflows.get(undefined, undefined, undefined, HEALTH_SECTIONS),
]);
setInfo(infoRes as any);
setWorkflow(workflowRes);
+341
View File
@@ -0,0 +1,341 @@
/**
* @file QueryExplorer.tsx
* @description `/query`: filter sessions, events or agents (free text, AI tool,
* status, local date range, folder subtree, plus the global data scope), sort
* by a column the server allows, page through the rows, and export the whole
* filtered set as CSV or JSON (`GET /api/query`, capped server-side).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Download, RotateCcw } from "lucide-react";
import { api } from "../lib/api";
import { applyRange } from "../lib/dateRange";
import { useDataScope } from "../lib/dataScope";
import { useAiTools } from "../hooks/useAiTools";
import { Select } from "../components/Select";
import type { QueryEntity, QueryPage } from "../lib/types";
const PAGE_SIZE = 50;
const ENTITIES: QueryEntity[] = ["sessions", "events", "agents"];
const STATUSES: Record<QueryEntity, string[]> = {
sessions: ["active", "completed", "error", "abandoned"],
events: [],
agents: ["working", "waiting", "completed", "error"],
};
export function QueryExplorer() {
const { t } = useTranslation("analytics");
const aiTools = useAiTools();
const [scope] = useDataScope();
const [entity, setEntity] = useState<QueryEntity>("sessions");
const [q, setQ] = useState("");
const [cwd, setCwd] = useState("");
const [typed, setTyped] = useState({ q: "", cwd: "" });
const [tool, setTool] = useState("");
const [status, setStatus] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [sort, setSort] = useState("");
const [dir, setDir] = useState<"asc" | "desc">("desc");
const [offset, setOffset] = useState(0);
const [page, setPage] = useState<QueryPage | null>(null);
const [error, setError] = useState<string | null>(null);
// Text filters wait for a pause in typing.
useEffect(() => {
const h = setTimeout(() => {
const next = { q: q.trim(), cwd: cwd.trim() };
setTyped((prev) => (prev.q === next.q && prev.cwd === next.cwd ? prev : next));
setOffset(0);
}, 300);
return () => clearTimeout(h);
}, [q, cwd]);
/** Every filter change starts over at the first page. */
const fresh =
<T,>(set: (v: T) => void) =>
(v: T) => {
set(v);
setOffset(0);
};
const filters = useMemo(() => {
const p = new URLSearchParams({ entity });
if (typed.q) p.set("q", typed.q);
if (typed.cwd) p.set("cwd", typed.cwd);
if (tool) p.set("tool", tool);
if (status) p.set("status", status);
if (sort) {
p.set("sort", sort);
p.set("dir", dir);
}
return applyRange(p, { from: from || undefined, to: to || undefined });
}, [entity, typed, tool, status, sort, dir, from, to]);
useEffect(() => {
let live = true;
const p = new URLSearchParams(filters);
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(offset));
api.query
.page(p)
.then((r) => {
if (!live) return;
setPage(r);
setError(null);
})
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [filters, offset, scope]);
const switchEntity = (v: QueryEntity) => {
setEntity(v);
setStatus("");
setSort("");
setDir("desc");
setOffset(0);
setPage(null);
};
const reset = () => {
setQ("");
setCwd("");
setTool("");
setStatus("");
setFrom("");
setTo("");
setSort("");
setDir("desc");
setOffset(0);
};
const sortedBy = sort || page?.sortable[0] || "";
// A click on the current column flips it; another column starts descending.
const sortOn = (col: string) => {
setDir(col === sortedBy && dir === "desc" ? "asc" : "desc");
setSort(col);
setOffset(0);
};
const total = page?.total ?? 0;
const cap = page?.export_cap ?? 50_000;
const statuses = STATUSES[entity];
return (
<div className="animate-fade-in space-y-5">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("query.title")}</h1>
<p className="text-sm text-fg-muted">{t("query.subtitle")}</p>
</div>
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="w-36">
<Select
value={entity}
onChange={switchEntity}
label={t("query.entity")}
options={ENTITIES.map((v) => ({ value: v, label: t(`query.entities.${v}`) }))}
/>
</div>
<input
className="input text-sm w-56"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("query.searchPlaceholder")}
aria-label={t("query.search")}
/>
<div className="w-40">
<Select
value={tool}
onChange={fresh(setTool)}
label={t("toolFilter")}
options={[
{ value: "", label: t("allTools") },
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
]}
/>
</div>
{statuses.length > 0 && (
<div className="w-40">
<Select
value={status}
onChange={fresh(setStatus)}
label={t("query.status")}
options={[
{ value: "", label: t("query.allStatuses") },
...statuses.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
]}
/>
</div>
)}
<input
type="date"
className="input text-sm"
value={from}
max={to || undefined}
onChange={(e) => fresh(setFrom)(e.target.value)}
aria-label={t("query.from")}
title={t("query.from")}
/>
<input
type="date"
className="input text-sm"
value={to}
min={from || undefined}
onChange={(e) => fresh(setTo)(e.target.value)}
aria-label={t("query.to")}
title={t("query.to")}
/>
<input
className="input text-sm w-56 font-mono"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
placeholder={t("query.cwdPlaceholder")}
aria-label={t("query.cwd")}
title={t("query.cwd")}
/>
<button type="button" className="btn-ghost" onClick={reset} title={t("query.reset")}>
<RotateCcw className="w-4 h-4" />
{t("query.reset")}
</button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-fg-muted">
{total > cap
? t("query.exportTruncated", {
total: total.toLocaleString(),
cap: cap.toLocaleString(),
})
: t("query.exportNote", { cap: cap.toLocaleString() })}
</p>
<div className="flex items-center gap-2">
<a className="btn-ghost" href={api.query.exportUrl(filters, "csv")} download>
<Download className="w-4 h-4" />
{t("query.exportCsv")}
</a>
<a className="btn-ghost" href={api.query.exportUrl(filters, "json")} download>
<Download className="w-4 h-4" />
{t("query.exportJson")}
</a>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{page && page.rows.length === 0 ? (
<p className="py-10 text-center text-sm text-fg-muted">{t("query.empty")}</p>
) : (
page && (
<div className="card overflow-x-auto">
<table className="w-full text-sm" data-testid="query-table">
<thead>
<tr className="border-b border-border text-left">
{page.columns.map((c) => {
const sortable = page.sortable.includes(c);
return (
<th
key={c}
className="px-3 py-2 text-[11px] font-semibold text-fg-muted font-mono"
aria-sort={
c === sortedBy ? (dir === "asc" ? "ascending" : "descending") : undefined
}
>
{sortable ? (
<button
type="button"
onClick={() => sortOn(c)}
className="inline-flex items-center gap-1 hover:text-fg-primary"
title={t("query.sortBy", { column: c })}
>
{c}
{c === sortedBy &&
(dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
))}
</button>
) : (
c
)}
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-border">
{page.rows.map((r, i) => (
<tr key={`${r.id}-${i}`} className="hover:bg-surface-4">
{page.columns.map((c) => {
const v = r[c];
const sid =
(entity === "sessions" && c === "id") || c === "session_id" ? v : null;
return (
<td
key={c}
className="px-3 py-1.5 max-w-[22rem] truncate text-fg-secondary"
title={v == null ? undefined : String(v)}
>
{sid ? (
<Link
to={`/sessions/${encodeURIComponent(String(sid))}`}
className="font-mono text-accent hover:underline"
>
{String(sid).slice(0, 8)}
</Link>
) : (
(v ?? "")
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)
)}
{page && total > 0 && (
<div className="flex items-center justify-end gap-2 text-xs text-fg-muted">
<span>
{t("query.rows", {
from: (offset + 1).toLocaleString(),
to: Math.min(offset + PAGE_SIZE, total).toLocaleString(),
total: total.toLocaleString(),
})}
</span>
<button
type="button"
className="btn-ghost"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
title={t("query.prev")}
aria-label={t("query.prev")}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
className="btn-ghost"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
title={t("query.next")}
aria-label={t("query.next")}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}
+56 -109
View File
@@ -1,6 +1,6 @@
/**
* @file SessionDetail.tsx
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view.
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view; a Replay tab scrubs through its events and a Snapshot action freezes it under a share link.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -28,7 +28,7 @@
* - `../components/conversation/ConversationView`
* - `../components/StatusBadge`
* - `../lib/types`
* - `../components/EventDetail`
* - `../components/EventTimeline`, `../components/EventReplay`, `../components/SnapshotButton`
* - `../components/EventFilters`
* - `../components/EventFiltersInfo`
* - `../components/Skeleton`
@@ -79,6 +79,7 @@ import {
Hourglass,
X,
Download,
Film,
} from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
@@ -89,9 +90,12 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { AgentCard } from "../components/AgentCard";
import { SessionOverview } from "../components/SessionOverview";
import { ConversationView } from "../components/conversation/ConversationView";
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
import { SnapshotButton } from "../components/SnapshotButton";
import { EventTimeline } from "../components/EventTimeline";
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
import { CopyButton } from "../components/event-views/primitives";
import {
effectiveSessionStatus,
@@ -99,7 +103,6 @@ import {
sessionAwaitingReason,
AWAITING_REASON_CONFIG,
} from "../lib/types";
import { EventDetail } from "../components/EventDetail";
import {
EventFilters,
isEmptyFilters,
@@ -110,14 +113,6 @@ import {
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
import { Skeleton } from "../components/Skeleton";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import {
formatDateTime,
@@ -139,7 +134,7 @@ import type {
} from "../lib/types";
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
type DetailTab = "agents" | "conversation" | "timeline";
type DetailTab = "agents" | "conversation" | "timeline" | "replay";
const EVENTS_INITIAL_BATCH = 50;
const EVENTS_MORE_BATCH = 500;
@@ -204,16 +199,18 @@ export function SessionDetail() {
const [pendingTranscriptId, setPendingTranscriptId] = useState<string | null>(null);
const [transcriptNotFound, setTranscriptNotFound] = useState(false);
const notFoundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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;
});
}
// The Replay tab's own copy: every event, oldest first, read on first visit.
const [replay, setReplay] = useState<{ events: DashboardEvent[]; total: number } | null>(null);
useEffect(() => {
if (activeTab !== "replay" || replay || !id) return;
let live = true;
loadReplayEvents(id)
.then((r) => live && setReplay(r))
.catch(() => live && setReplay({ events: [], total: 0 }));
return () => {
live = false;
};
}, [activeTab, replay, id]);
// Refs let the websocket handler access latest state without re-subscribing.
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
@@ -427,13 +424,6 @@ export function SessionDetail() {
return map;
}, [session?.id, session?.name]);
// 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]);
const loadEvents = useCallback(async () => {
if (!eventApiParams) return;
try {
@@ -680,6 +670,7 @@ export function SessionDetail() {
) : null;
})()}
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
<button
onClick={load}
className="btn-ghost"
@@ -822,6 +813,20 @@ export function SessionDetail() {
<List className="w-4 h-4" />
{t("detail.timeline", { shown: events.length, total: eventsTotal })}
</button>
<button
onClick={() => {
setActiveTab("replay");
setTranscriptNotFound(false);
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "replay"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<Film className="w-4 h-4" />
{t("replay.tab")}
</button>
</div>
{/* Tab Content */}
@@ -1139,85 +1144,12 @@ export function SessionDetail() {
{isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")}
</p>
) : (
<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(session?.cwd) ?? 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>
<EventTimeline
events={events}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
fallbackCwd={session?.cwd}
/>
)}
{events.length < eventsTotal && (
<div className="flex items-center justify-between mt-3 px-1">
@@ -1238,6 +1170,21 @@ export function SessionDetail() {
)}
</div>
)}
{visitedTabs.has("replay") && (
<div hidden={activeTab !== "replay"}>
{replay ? (
<EventReplay
events={replay.events}
total={replay.total}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
) : (
<p className="text-sm text-fg-muted">{t("replay.loading")}</p>
)}
</div>
)}
</div>
);
}
+14 -25
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as four pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — language —, `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s (Sagi has its own page, `components/settings/PetSettings.tsx`). Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -105,6 +105,9 @@ import { notify, playSound } from "../lib/notify";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import {
@@ -205,12 +208,12 @@ function useCountUp(end: number | null, durationMs = 1000) {
// ─── Main component ───
export function Settings({
section = "general",
section = "notifications",
}: {
/** Which Settings page this renders (`/settings/<section>`). */
section?: "general" | "data" | "about" | "notifications";
section?: "data" | "about" | "notifications";
} = {}) {
const { t, i18n } = useTranslation("settings");
const { t } = useTranslation("settings");
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -541,27 +544,6 @@ export function Settings({
</div>
)}
{section === "general" && (
<>
<SettingsSection title={t("language.title")} description={t("language.description")}>
<SettingsRow
label={t("language.label")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: t("language.names.en") },
{ value: "vi" as const, label: t("language.names.vi") },
]}
/>
}
/>
</SettingsSection>
</>
)}
{section === "notifications" && (
<>
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p>
@@ -983,6 +965,10 @@ export function Settings({
<RemoteSources />
</section>
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings />
<PrivacySettings />
{/* ─── SESSION CLEANUP ─── */}
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
<div className="flex items-center gap-3">
@@ -1136,6 +1122,9 @@ export function Settings({
{section === "about" && (
<section id="about" className="scroll-mt-24">
<div className="mb-4">
<AutostartSettings />
</div>
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
{sysInfo ? (
<div className="card p-5">
+42 -26
View File
@@ -9,7 +9,8 @@
* element with that `id` or `data-settings-anchor` and briefly highlights it.
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to General. A tool's own settings live under `agents/<id>` — Claude Code and
* and falls back to Appearance (which also holds the language and the pet;
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
@@ -32,18 +33,14 @@ import {
Database,
Info,
Bot,
Boxes,
Camera,
CalendarClock,
Coins,
GitBranch,
KeyRound,
Orbit,
Palette,
PawPrint,
Puzzle,
Router,
Search,
Settings as SettingsIcon,
SquareTerminal,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -52,7 +49,8 @@ import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { PetSettings } from "../components/settings/PetSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -64,6 +62,7 @@ import {
type SettingsEntry,
} from "../lib/settingsSearch";
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
@@ -80,7 +79,7 @@ interface Section {
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = {
dashboard: "general",
dashboard: "appearance",
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-router",
@@ -104,13 +103,29 @@ const historyIdx = (): number => {
export const backDelta = (current: number, entry: number): number =>
-Math.max(1, current - entry + 1);
/** An unknown section: say so, then show General. */
/** Sections folded into Appearance: old links and bookmarks still land. */
/** A tool's own mark as a nav icon, one stable component per tool id. */
const navIcons = new Map<string, ComponentType<{ className?: string }>>();
function toolNavIcon(id: string, label: string) {
let Icon = navIcons.get(id);
if (!Icon) {
Icon = ({ className }) => (
<AiToolIcon tool={{ id, label }} className={`${className ?? ""} !bg-transparent !p-0`} />
);
navIcons.set(id, Icon);
}
return Icon;
}
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to="/settings/general" replace />;
return <Navigate to="/settings/appearance" replace />;
}
export function SettingsHub() {
@@ -191,13 +206,6 @@ export function SettingsHub() {
description: t(`settingsNav.section.${key}.description`),
});
const sections: Section[] = [
{
id: "general",
group: "general",
Icon: SettingsIcon,
...s("general"),
render: () => <Settings section="general" />,
},
{
id: "data",
group: "general",
@@ -220,11 +228,18 @@ export function SettingsHub() {
render: () => <AppearanceSettings />,
},
{
id: "pet",
id: "snapshots",
group: "general",
Icon: PawPrint,
...s("pet"),
render: () => <PetSettings />,
Icon: Camera,
...s("snapshots"),
render: () => <SnapshotSettings />,
},
{
id: "reports",
group: "general",
Icon: CalendarClock,
...s("reports"),
render: () => <ReportSettings />,
},
{
id: "notifications",
@@ -245,7 +260,7 @@ export function SettingsHub() {
{
id: "agents/claude-code",
group: "agents" as const,
Icon: Boxes,
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
render: () => <CcConfig />,
},
@@ -256,7 +271,7 @@ export function SettingsHub() {
{
id: "agents/codex",
group: "agents" as const,
Icon: SquareTerminal,
Icon: toolNavIcon("codex", "Codex"),
...s("codex"),
render: () => <CodexConfig />,
},
@@ -269,7 +284,7 @@ export function SettingsHub() {
.map((x) => ({
id: `agents/${x.id}`,
group: "agents" as const,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
Icon: toolNavIcon(x.id, x.label),
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
@@ -349,9 +364,10 @@ export function SettingsHub() {
? "agents"
: tool
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
: "general";
: "appearance";
return <Navigate to={`/settings/${to}`} replace />;
}
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
+191
View File
@@ -0,0 +1,191 @@
/**
* @file SnapshotView.tsx
* @description The read-only page `/snapshot/:token`: a session as it was frozen
* by `POST /api/snapshots` — header, SessionOverview fed the frozen stats, the
* agents, and the Replay / Timeline tabs over the frozen events. Nothing here
* reads live data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Bot, Camera, Clock, DollarSign, Film, List, ShieldCheck } from "lucide-react";
import { api } from "../lib/api";
import type { AgentInfo } from "../lib/event-grouping";
import { formatDateTime, fmtCostFull } from "../lib/format";
import type { SessionSnapshotInfo, SessionSnapshotPayload } from "../lib/types";
import { SessionOverview } from "../components/SessionOverview";
import { AgentCard } from "../components/AgentCard";
import { EventTimeline } from "../components/EventTimeline";
import { EventReplay } from "../components/EventReplay";
import { SessionStatusBadge } from "../components/StatusBadge";
import { Skeleton } from "../components/Skeleton";
type Tab = "overview" | "replay" | "timeline";
export function SnapshotView() {
const { token = "" } = useParams<{ token: string }>();
const { t } = useTranslation("sessions");
const [data, setData] = useState<{
snapshot: SessionSnapshotInfo;
payload: SessionSnapshotPayload;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
useEffect(() => {
let live = true;
setData(null);
setError(null);
api.snapshots
.get(token)
.then((r) => live && setData(r))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [token]);
const payload = data?.payload;
const agentInfoById = useMemo(() => {
const map = new Map<string, AgentInfo>();
for (const a of payload?.agents ?? []) map.set(a.id, a);
return map;
}, [payload]);
const sessionNameById = useMemo(() => {
const s = payload?.session;
return new Map(s ? [[s.id, s.name?.trim() || `Session ${s.id.slice(0, 8)}`]] : []);
}, [payload]);
if (error) {
return (
<div className="py-20 text-center">
<p className="mb-2 text-status-danger">{t("snapshot.notFound")}</p>
<p className="text-xs text-fg-muted">{error}</p>
</div>
);
}
if (!payload) {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-24 w-full" />
</div>
);
}
const { session, agents, events, stats, cost } = payload;
const tabs: { id: Tab; label: string; Icon: typeof Bot }[] = [
{ id: "overview", label: `${t("detail.agents")} (${agents.length})`, Icon: Bot },
{ id: "replay", label: t("replay.tab"), Icon: Film },
{ id: "timeline", label: t("snapshot.timeline", { n: events.length }), Icon: List },
];
return (
<div className="animate-fade-in space-y-6">
<div className="flex items-start gap-3 rounded-lg border border-accent/30 bg-accent/10 px-4 py-2.5 text-sm">
<Camera className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="font-medium text-fg-primary">
{t("snapshot.banner", { date: formatDateTime(payload.captured_at) })}
</div>
<div className="text-[11px] text-fg-muted">{t("snapshot.bannerBody")}</div>
</div>
{payload.redacted.file_paths && (
<span className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<ShieldCheck className="h-3.5 w-3.5" />
{t("snapshot.redacted")}
</span>
)}
<Link to={`/sessions/${session.id}`} className="text-[11px] text-accent hover:underline">
{t("snapshot.openLive")}
</Link>
</div>
<div>
<div className="mb-2 flex items-center gap-3">
<h2 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2>
<SessionStatusBadge status={session.status} />
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="rounded bg-surface-2 px-2 py-1 font-mono text-xs text-fg-muted">
{session.id.slice(0, 16)}
</span>
<span className="inline-flex items-center gap-1.5 rounded bg-surface-2 px-2 py-1 text-xs text-fg-secondary">
<Clock className="h-3 w-3 text-fg-muted" />
{formatDateTime(session.started_at)}
</span>
{cost.total_cost > 0 && (
<span className="inline-flex items-center gap-1.5 rounded bg-status-success/10 px-2 py-1 text-xs font-medium text-status-success">
<DollarSign className="h-3 w-3" />
{fmtCostFull(cost.total_cost).slice(1)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 border-b border-border">
{tabs.map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
tab === id
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{tab === "overview" && (
<div>
{/* Frozen: its duration ends at the capture, not at "now". */}
<SessionOverview
session={{ ...session, ended_at: session.ended_at ?? payload.captured_at }}
agents={agents}
stats={stats}
/>
<div className="space-y-2">
{[...agents]
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""))
.map((a) => (
<AgentCard
key={a.id}
agent={a}
session={{ ...session, cost: cost.total_cost }}
// Without a handler the card opens the LIVE session.
onClick={() => undefined}
/>
))}
</div>
</div>
)}
{tab === "replay" && (
<EventReplay
events={events}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
{tab === "timeline" &&
(events.length === 0 ? (
<p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>
) : (
<EventTimeline
events={[...events].reverse()}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
fallbackCwd={session.cwd}
/>
))}
</div>
);
}
+8 -3
View File
@@ -75,6 +75,7 @@ import {
import { useTranslation } from "react-i18next";
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
import { api } from "../lib/api";
import type { DateRange } from "../lib/dateRange";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import type { WorkflowData, WSMessage } from "../lib/types";
@@ -91,7 +92,11 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
type StatusFilter = "all" | "active" | "completed";
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
export function Workflows({
embedded = false,
tool,
range,
}: { embedded?: boolean; tool?: string; range?: DateRange } = {}) {
const { t } = useTranslation("workflows");
const [data, setData] = useState<WorkflowData | null>(null);
const [loading, setLoading] = useState(true);
@@ -108,7 +113,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
const fetchData = useCallback(async () => {
const seq = ++fetchSeq.current;
try {
const result = await api.workflows.get(statusFilter);
const result = await api.workflows.get(statusFilter, tool, range);
if (seq !== fetchSeq.current) return;
setError(null);
setData(result);
@@ -118,7 +123,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
} finally {
if (seq === fetchSeq.current) setLoading(false);
}
}, [statusFilter]);
}, [statusFilter, tool, range?.from, range?.to]);
const selectSession = useCallback((id: string | null) => {
setSelectedSessionId(id);
+19 -56
View File
@@ -61,8 +61,6 @@ import {
PanelLeftClose,
PanelRightClose,
Maximize2,
FolderTree,
GitCompare,
ChartColumn,
} from "lucide-react";
import { api } from "../lib/api";
@@ -120,17 +118,9 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the right panel's activity-bar tab. */
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
const PANEL_TABS = [
{ id: "files", Icon: FolderTree },
{ id: "changes", Icon: GitCompare },
] as const;
type PanelTab = (typeof PANEL_TABS)[number]["id"];
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
@@ -600,21 +590,6 @@ export function Workspace() {
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
try {
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
} catch {
return "changes";
}
});
useEffect(() => {
try {
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
} catch {
/* the panel just forgets its tab between visits */
}
}, [panelTab]);
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
// order (client/src/lib/laneOrderStorage.ts).
@@ -950,10 +925,11 @@ export function Workspace() {
<div
className={
sessionView
? // Cancels Layout's page padding; the 1.5rem left is the status bar's.
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
? // Cancels Layout's page padding; the 1.5rem left is the status bar's,
// plus the drawer's 2.75rem menu bar below `md` (issue #61).
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col max-md:h-[calc(100vh-4.25rem)] lg:-m-6"
: viewportLocked
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
? "h-[calc(100vh-4rem)] max-md:h-[calc(100vh-6.75rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
: "space-y-5"
}
>
@@ -1231,36 +1207,17 @@ export function Workspace() {
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Activity bar: Files · Changes · Sessions, then the resize
control — the same height as the console's tab bar, so the
two read as one header strip. */}
{/* Header: the title, Analyst, then the resize control — the
same height as the console's tab bar, so the two read as
one header strip. Files and Changes are ONE panel now; its
own mode switcher moves between them. */}
<div
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
role="toolbar"
>
{PANEL_TABS.map(({ id, Icon }) => (
<button
key={id}
type="button"
data-testid={`panel-tab-${id}`}
aria-pressed={panelTab === id}
// Narrow, a tab switch leaves the open file (and its
// edit); at full width the editor stays put.
onClick={() =>
panelTab !== id &&
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
}
title={tLanes(`panel.${id}`)}
aria-label={tLanes(`panel.${id}`)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
panelTab === id
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
<span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")}
</span>
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
@@ -1318,8 +1275,8 @@ export function Workspace() {
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
tab={panelTab}
full={changesFull}
wide={changesPreset !== "compact"}
onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
@@ -1355,7 +1312,13 @@ export function Workspace() {
{tLanes("changes.title")}
</span>
</button>
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
{panelAt && (
<LaneGitRailCounts
laneId={panelAt.laneId}
session={panelAt.session}
onShowAll={() => setChangesOpen(true)}
/>
)}
</div>
))}
</div>
@@ -104,11 +104,30 @@ describe("AiRouterConfig", () => {
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
@@ -0,0 +1,95 @@
/**
* @file QueryExplorer.test.tsx
* @description The Query Explorer page: it renders a page of rows with session
* links, sorts by an allowed column on a header click (flipping on a second),
* switches entity, and points both export links at the current filter.
* @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 { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { query } = vi.hoisted(() => ({
query: {
page: vi.fn(),
exportUrl: (p: URLSearchParams, f: string) => `/api/query?${p.toString()}&format=${f}`,
},
}));
vi.mock("../../lib/api", () => ({ api: { query } }));
vi.mock("../../hooks/useAiTools", () => ({
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
}));
import { QueryExplorer } from "../QueryExplorer";
const page = (entity: string, rows: Record<string, string>[]) => ({
entity,
columns: Object.keys(rows[0] ?? {}),
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
statuses: [],
rows,
total: rows.length,
limit: 50,
offset: 0,
export_cap: 50000,
});
const lastParams = () => {
const calls = query.page.mock.calls;
return calls[calls.length - 1]![0] as URLSearchParams;
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
query.page.mockImplementation((p: URLSearchParams) =>
Promise.resolve(
p.get("entity") === "events"
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
)
);
});
describe("QueryExplorer", () => {
it("shows rows, sorts by an allowed column and exports the filter", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
expect(await screen.findByText("first")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
"href",
"/sessions/sess-abcdef12"
);
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
expect(lastParams().get("dir")).toBe("desc");
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("dir")).toBe("asc"));
// A column outside `sortable` has no sort button.
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
const csv = screen.getByRole("link", { name: /Export CSV/ });
expect(csv.getAttribute("href")).toContain("sort=name");
expect(csv.getAttribute("href")).toContain("format=csv");
});
it("switches entity and drops the status filter for events", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
await screen.findByText("first");
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Data/ }));
await user.click(screen.getByRole("option", { name: "Events" }));
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
});
});

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