Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 | |||
| 7543b8d62a | |||
| 4163f431bb | |||
| fcf8d8010a | |||
| 5fe5d51b1b | |||
| dd9b23106c | |||
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 | |||
| 03ecc9e29a | |||
| 4aae61297f | |||
| 837da6e5de |
@@ -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
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
+41
-11
@@ -325,14 +325,15 @@ 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 |
|
||||
| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status and `?sections=a,b` to compute only those parts. The `GET /` bundle itself lives in `lib/workflow-stats.js` and runs in the read worker (`lib/read-pool.js`). `GET /api/workflows/skill-usage` reports per-skill and per-plugin call counts, counting `PreToolUse` rows only so a call is not double-counted against its `PostToolUse` twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details |
|
||||
| `lib/workflow-stats.js` | The eleven `GET /api/workflows` aggregations as pure reads over a passed-in connection (`computeWorkflowData(conn, status, sections)`), so they run in the read worker. `stats.topFlow` is the head of `toolFlow.transitions`: tool pairs come from one `LEAD() OVER (PARTITION BY session_id ORDER BY id)` pass — never a correlated `MIN(id) … id > ?` per event, which is quadratic in a session's size |
|
||||
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there |
|
||||
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a caller's `maxAgeMs` reusing a result that young (`/api/workflows` 30 s, `/api/analytics`'s events scans 10 s — args are the key, so a member's scoped result is never shared), a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there. The server's WAL checkpoint (`walCheckpoint`, every `DASHBOARD_WAL_CHECKPOINT_MS`, default 5 s) also runs here, on a second writable connection of the worker: the main connection sets `wal_autocheckpoint = 0`, so no hook's `COMMIT` pays for a checkpoint; a failed checkpoint turns the automatic one back on |
|
||||
| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
|
||||
| `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p <pids> -d cwd -Fn` on macOS or `/proc/<pid>/cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` |
|
||||
| `bin/agenthub.js` | Dependency-free umbrella CLI (`agenthub <command>`) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agenthub.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `AGENTHUB_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `agenthub status` shows a ●/○ up/down indicator and `agenthub start` boots a detached production server (waits for /api/health, logs to `data/agenthub-server.log`). `agenthub repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.agenthub_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `agenthub` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason |
|
||||
@@ -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 |
|
||||
@@ -485,7 +487,7 @@ graph TD
|
||||
### Splash & loading UX
|
||||
|
||||
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
|
||||
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#924224` — Claude's terracotta three steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
||||
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#eceef2` and a `.card` is the brighter `surface-3` `#fdfdfe` (the light Default is IntelliJ Light's palette), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is IntelliJ's `#285ac0`, darker than dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking IntelliJ Light's editor scheme. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
||||
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
|
||||
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
||||
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
||||
@@ -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"
|
||||
@@ -909,10 +911,10 @@ erDiagram
|
||||
| `idx_agents_status` | agents | `status` | Kanban board column queries |
|
||||
| `idx_events_session_created` | events | `session_id, created_at` | A session's newest event / time order (watchdog every 15 s, every lane payload) |
|
||||
| `idx_events_tool` | events | `tool_name` | Per-tool counts (analytics, workflows) and the events filter's tool list without a scan of every event row |
|
||||
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type` (that one is not id-ordered); dropping it once made every hook sort its whole session |
|
||||
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type_tool` (that one is not id-ordered); dropping it once made every hook sort its whole session |
|
||||
| `idx_events_type` | events | `event_type` | Filter events by type |
|
||||
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
|
||||
| `idx_events_session_type` | events | `session_id, event_type` | Per-session event-type filters |
|
||||
| `idx_events_session_type_tool` | events | `session_id, event_type, tool_name, created_at` | Per-session event-type filters, and a covering read for session stats (per-tool and per-type counts), the hook's TurnDuration/APIError dedupe and the import's newest-per-type resume point (1.2 s → 20 ms on a 29K-event session). Replaces `idx_events_session_type` (dropped at startup), so a hook writes no more index entries than before |
|
||||
| `idx_events_agent_type` | events | `agent_id, event_type` | `importSubagentFromJsonl` reads the agent's existing `tool_use_id`s once through it (it used to run a `data LIKE` per tool event) |
|
||||
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
||||
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
|
||||
@@ -964,6 +966,11 @@ All messages are JSON with this envelope:
|
||||
}
|
||||
```
|
||||
|
||||
Frames a hook produces are sent only after its transaction committed and its
|
||||
pane binding (`bindRunSession`) ran — in order, in the same tick — so no frame
|
||||
describes a rolled-back row, and a server-mode member's first frames of a new
|
||||
session reach them. `new_event` carries the event's row `id` (additive).
|
||||
|
||||
The `remote_source.status` message (emitted by the **Remote Data Sources**
|
||||
sync poller and the `/api/remote-sources` routes) carries
|
||||
`{ id, status, error?, last_sync_at? }`, where `status` is one of
|
||||
@@ -1731,6 +1738,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:
|
||||
@@ -2271,8 +2301,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
||||
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
||||
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
||||
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
|
||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
|
||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. It then polls `status` and reloads once the old server went down and answered again, or as soon as a different `local_version` answers; a verdict of this chain (`finished_at` after the click) that is `ok:false` or `already_current` ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose `/api/health` `version` is not the bundle's (`client/src/lib/newBuildReload.ts`, once per version). |
|
||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner — a tab waiting on **Update now** stops waiting and says nothing new was pulled. |
|
||||
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
||||
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
||||
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -61,8 +61,10 @@ Internal build — all rights reserved.
|
||||
other its conversation with a prompt box — sending a message resumes it,
|
||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
||||
prompt box that starts a new session, Recents, and the workspace's own
|
||||
Instructions, Memory, Context files and extra Folders, which Claude and Codex
|
||||
sessions start with.
|
||||
Instructions, Memory, Context files (picked with + or dragged in from the OS)
|
||||
and extra Folders, which Claude and Codex sessions start with. Files and
|
||||
images dragged onto a terminal or a prompt box are uploaded and attached by
|
||||
their saved path.
|
||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||
@@ -78,7 +80,7 @@ Internal build — all rights reserved.
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
|
||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
Per-browser only.
|
||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||
@@ -86,6 +88,16 @@ 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)).
|
||||
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
|
||||
every page and API call, with admin / member / analyst roles. It is off by
|
||||
default, and a local install does not change. It is login only, without
|
||||
per-user isolation yet ([docs/SERVER-MODE.md](docs/SERVER-MODE.md)).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -197,7 +209,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 +283,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 +336,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 +351,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
@@ -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
|
||||
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
+350
-15
@@ -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
|
||||
@@ -171,7 +160,13 @@ async function api(method, pathname, body, options = {}) {
|
||||
const form = body instanceof FormData;
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
|
||||
headers: {
|
||||
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
||||
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||
...(process.env.AGENTHUB_PANE_TOKEN
|
||||
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
|
||||
: {}),
|
||||
},
|
||||
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||
});
|
||||
@@ -1037,6 +1032,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);
|
||||
@@ -2312,6 +2309,17 @@ async function cmdLockAcquire(args) {
|
||||
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||
return;
|
||||
}
|
||||
// Only 409 means "held by someone else"; any other answer (a refused
|
||||
// name, a server error) will not change by waiting.
|
||||
if (result.status !== 409) {
|
||||
console.error(
|
||||
c.red(
|
||||
`✖ ${result.data?.error?.message || `could not acquire lock (HTTP ${result.status})`}`
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastPrinted >= 60_000) {
|
||||
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||
console.log(
|
||||
@@ -2728,6 +2736,21 @@ const COMMAND_GROUPS = [
|
||||
"install|uninstall|status [--port N]",
|
||||
"Auto-start on boot via systemd --user (Linux)",
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"setup|off|status [--hostname name] [--admin name]",
|
||||
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||
],
|
||||
[
|
||||
"users",
|
||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||
"Server mode's accounts (add: --role admin|analyst|member)",
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name]",
|
||||
"Claude account pool: new sessions spread over these accounts",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
],
|
||||
@@ -2797,7 +2820,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 +2940,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",
|
||||
[
|
||||
@@ -3661,11 +3700,291 @@ const COMMANDS = (() => {
|
||||
const SUBCOMMANDS = {
|
||||
alerts: ["ack", "ack-all"],
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove"],
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server mode (docs/SERVER-MODE.md) ──────────────────────────────────────
|
||||
|
||||
/** The auth module over the SAME database and settings the server reads: the
|
||||
* repo `.env`, then AgentHub's settings file (a pinned DASHBOARD_DATA_DIR). */
|
||||
function serverAuth() {
|
||||
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
|
||||
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
|
||||
return { settingsEnv, auth: require(path.join(REPO_ROOT, "server", "lib", "auth.js")) };
|
||||
}
|
||||
|
||||
/** One password: typed hidden on a terminal, else the first line of stdin. */
|
||||
async function askPassword(prompt) {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY) {
|
||||
const chunks = [];
|
||||
for await (const chunk of stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString("utf8").split(/\r?\n/)[0];
|
||||
}
|
||||
process.stdout.write(prompt);
|
||||
return new Promise((resolve, reject) => {
|
||||
let typed = "";
|
||||
const finish = () => {
|
||||
stdin.setRawMode(false);
|
||||
stdin.pause();
|
||||
stdin.off("data", onData);
|
||||
process.stdout.write("\n");
|
||||
};
|
||||
const onData = (text) => {
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") return (finish(), resolve(typed));
|
||||
if (ch === "\u0003") return (finish(), reject(new Error("cancelled")));
|
||||
typed = ch === "\u007f" || ch === "\b" ? typed.slice(0, -1) : typed + ch;
|
||||
}
|
||||
};
|
||||
stdin.setRawMode(true);
|
||||
stdin.setEncoding("utf8");
|
||||
stdin.on("data", onData);
|
||||
stdin.resume();
|
||||
});
|
||||
}
|
||||
|
||||
/** A new password, typed twice on a terminal. */
|
||||
async function newPassword(label) {
|
||||
const first = await askPassword(`${label}: `);
|
||||
if (process.stdin.isTTY && (await askPassword("Again: ")) !== first) {
|
||||
throw new Error("the two passwords differ");
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
||||
async function cmdServer(sub, flags) {
|
||||
const { settingsEnv, auth } = serverAuth();
|
||||
const file = settingsEnv.settingsFilePath();
|
||||
const envNote = (key) => {
|
||||
if (settingsEnv.envOwned.has(key)) {
|
||||
console.log(
|
||||
c.yellow(`! ${key} is also set in the environment or .env, which wins over ${file}`)
|
||||
);
|
||||
}
|
||||
};
|
||||
if (sub === "setup") {
|
||||
if (flags.hostname) {
|
||||
const hosts = String(flags.hostname);
|
||||
if (!/^[a-z0-9.\-:[\],]+$/i.test(hosts)) throw new Error(`bad --hostname: ${hosts}`);
|
||||
settingsEnv.writeSetting("DASHBOARD_ALLOWED_HOSTS", hosts);
|
||||
envNote("DASHBOARD_ALLOWED_HOSTS");
|
||||
}
|
||||
if (!auth.hasAdmin()) {
|
||||
const name = typeof flags.admin === "string" ? flags.admin : "admin";
|
||||
const user = await auth.createUser(
|
||||
name,
|
||||
await newPassword(`Password for admin ${name}`),
|
||||
"admin"
|
||||
);
|
||||
console.log(`${c.green("✔")} admin ${user.username} created`);
|
||||
} else {
|
||||
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||
}
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||
envNote("AGENTHUB_MODE");
|
||||
console.log(`${c.green("✔")} server mode on (${file})`);
|
||||
console.log(" Next:");
|
||||
console.log(
|
||||
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||
);
|
||||
console.log(
|
||||
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
||||
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
||||
);
|
||||
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
||||
console.log(
|
||||
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "off") {
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
|
||||
envNote("AGENTHUB_MODE");
|
||||
console.log(`${c.green("✔")} server mode off — restart the dashboard. Users are kept.`);
|
||||
return;
|
||||
}
|
||||
if (sub === "status" || sub === undefined) {
|
||||
const users = auth.listUsers();
|
||||
console.log(`mode: ${auth.isServerMode() ? "server" : "local"}`);
|
||||
console.log(`settings: ${file}`);
|
||||
console.log(
|
||||
`users: ${users.length} (${users.filter((u) => u.role === "admin").length} admin)`
|
||||
);
|
||||
console.log(c.dim(" a change takes effect when the dashboard restarts"));
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
|
||||
async function cmdUsers(sub, positional, flags) {
|
||||
const { auth } = serverAuth();
|
||||
const name = positional[0];
|
||||
const need = () => {
|
||||
if (!name) throw new Error(`usage: agenthub users ${sub} <username>`);
|
||||
};
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
case "list":
|
||||
table(
|
||||
["id", "username", "role", "state"],
|
||||
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||
);
|
||||
return;
|
||||
case "add": {
|
||||
need();
|
||||
const role = typeof flags.role === "string" ? flags.role : "member";
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||
return;
|
||||
}
|
||||
case "passwd":
|
||||
need();
|
||||
await auth.setPassword(name, await newPassword(`New password for ${name}`));
|
||||
console.log(`${c.green("✔")} password changed — ${name}'s logins ended`);
|
||||
return;
|
||||
case "role":
|
||||
need();
|
||||
auth.setRole(name, positional[1]);
|
||||
console.log(`${c.green("✔")} ${name} is now ${positional[1]}`);
|
||||
return;
|
||||
case "assign":
|
||||
if (!name || !positional[1]) {
|
||||
throw new Error("usage: agenthub users assign <workspace id> <username>");
|
||||
}
|
||||
auth.assignWorkspace(name, positional[1]);
|
||||
console.log(`${c.green("✔")} workspace ${name} now belongs to ${positional[1]}`);
|
||||
return;
|
||||
case "audit": {
|
||||
const { listAudit } = require(path.join(REPO_ROOT, "server", "lib", "audit.js"));
|
||||
table(
|
||||
["when", "reader", "owner", "path"],
|
||||
listAudit(typeof flags.limit === "string" ? flags.limit : 50).map((a) => [
|
||||
a.at.replace("T", " ").slice(0, 19),
|
||||
a.reader,
|
||||
a.owner,
|
||||
a.path,
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "disable":
|
||||
case "enable":
|
||||
need();
|
||||
auth.setDisabled(name, sub === "disable");
|
||||
console.log(`${c.green("✔")} ${name} ${sub}d`);
|
||||
return;
|
||||
default:
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|member] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||
async function cmdAccounts(sub, positional) {
|
||||
serverAuth(); // the same settings + database the server reads
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||
const name = positional[0];
|
||||
if (sub === "add" || sub === "remove") {
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
|
||||
}
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
);
|
||||
const added = pool.addAccount(name, await askPassword(`Token for ${name}: `));
|
||||
console.log(
|
||||
`${c.green("✔")} ${added.name} added — valid until ${added.expiresAt.slice(0, 10)}`
|
||||
);
|
||||
console.log(c.dim(" New Claude sessions now spread over the pool's accounts."));
|
||||
return;
|
||||
}
|
||||
if (sub === "remove") {
|
||||
pool.removeAccount(name);
|
||||
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||
return;
|
||||
}
|
||||
if (sub === undefined || sub === "list") {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
table(
|
||||
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
a.name,
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
pct(a.quota?.fiveHour),
|
||||
pct(a.quota?.sevenDay),
|
||||
a.expired
|
||||
? "expired"
|
||||
: a.full
|
||||
? "full"
|
||||
: a.quota?.error
|
||||
? `no quota (${a.quota.error})`
|
||||
: "ok",
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
|
||||
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
|
||||
@@ -3683,6 +4002,12 @@ async function runCommand(argv) {
|
||||
return cmdStart(flags);
|
||||
case "service":
|
||||
return cmdService(positional[0], flags);
|
||||
case "server":
|
||||
return cmdServer(positional[0], flags);
|
||||
case "users":
|
||||
return cmdUsers(positional[0], positional.slice(1), flags);
|
||||
case "accounts":
|
||||
return cmdAccounts(positional[0], positional.slice(1));
|
||||
case "stats":
|
||||
return cmdStats();
|
||||
case "kanban":
|
||||
@@ -3800,6 +4125,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":
|
||||
|
||||
+19
-7
@@ -54,11 +54,11 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **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 → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
Other rules that fall out of this:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
|
||||
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
|
||||
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
|
||||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
@@ -199,13 +199,17 @@ 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
|
||||
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
|
||||
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
|
||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
||||
│ │ ├── 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
|
||||
@@ -255,7 +259,7 @@ graph TB
|
||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||
Feed[ActivityFeed<br/>streaming event log]
|
||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
|
||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||
@@ -501,6 +505,8 @@ function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
{/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
|
||||
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||||
@@ -519,7 +525,7 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||
Every page — `Dashboard`, `History`, `Workspace` (the diff viewer), `SettingsHub`, `SessionDetail`, `SnapshotView` — is a `React.lazy` import in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so it loads on first visit instead of in the initial bundle; `main.tsx` starts the Workspace chunk early on a `/workspace` deep link. xterm and its addons load with the first terminal (`loadXterm` in `TerminalView.tsx`), the sidebar's add/destructive workspace modals on first open, and Sagi only while it is enabled (`SagiMount`). Only the active UI language is fetched (`i18n/index.ts`, one lazy chunk per language; `main.tsx` renders once it is in). `vite build` writes hidden source maps and a `.br`/`.gz` sibling of every text asset ≥ 1 KB. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
@@ -787,6 +793,12 @@ in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
||||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||||
the `.md-preview` rules in `index.css`, built from theme tokens.
|
||||
|
||||
#### Metric tooltips (`common:insights.help.*`)
|
||||
|
||||
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
|
||||
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
|
||||
`vi`.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
+9
-21
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* @description Service Worker for caching static assets and handling push notifications.
|
||||
* @description Service Worker: network-first navigations with an offline fallback, and push
|
||||
* notifications. Hashed `/assets/` bundles are left to the HTTP cache (immutable).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// Bump this any time the SW logic changes - old clients will install the new
|
||||
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
||||
// freshly-built bundle starts being served on the very next request.
|
||||
const CACHE_NAME = "dashboard-v2";
|
||||
// v3: drops v2's cache-first `/assets/` entries, which grew with every build
|
||||
// and were never pruned.
|
||||
const CACHE_NAME = "dashboard-v3";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
// No pre-cache: network-first below means the cache fills lazily, and
|
||||
@@ -39,25 +42,10 @@ self.addEventListener("fetch", (event) => {
|
||||
)
|
||||
return;
|
||||
|
||||
// Hashed bundles under /assets/ are immutable for a given URL - cache-first
|
||||
// is safe and fast. A new build emits new filenames, so stale entries simply
|
||||
// don't get re-requested.
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(request).then(
|
||||
(cached) =>
|
||||
cached ||
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Hashed bundles under /assets/ are immutable and the server marks them so:
|
||||
// the browser's HTTP cache keeps them. Caching them here too only grew a
|
||||
// second copy of every build, forever.
|
||||
if (url.pathname.startsWith("/assets/")) return;
|
||||
|
||||
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
||||
// with cache fallback. The user always gets the freshest UI while online and
|
||||
|
||||
+39
-7
@@ -38,8 +38,8 @@
|
||||
* ## Internal dependencies
|
||||
* - `./components/Layout`
|
||||
* - `./components/SplashScreen`
|
||||
* - `./pages/Dashboard`
|
||||
* - `./pages/History`
|
||||
* - `./pages/Dashboard` (lazy)
|
||||
* - `./pages/History` (lazy)
|
||||
* - `./pages/SessionDetail` (lazy)
|
||||
* - `./pages/SettingsHub` (lazy)
|
||||
* - `./pages/Workspace` (lazy)
|
||||
@@ -64,17 +64,19 @@ import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Toaster } from "./components/Toaster";
|
||||
import { ConfirmHost } from "./components/ConfirmHost";
|
||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { History } from "./pages/History";
|
||||
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 { reloadOnNewBuild } from "./lib/newBuildReload";
|
||||
import type { WSMessage } from "./lib/types";
|
||||
|
||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||
// visit instead of in the initial bundle.
|
||||
// visit instead of in the initial bundle — the Dashboard and History too, so the
|
||||
// entry chunk holds only the shell.
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||
const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History })));
|
||||
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
||||
const SettingsHub = lazy(() =>
|
||||
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
||||
@@ -82,6 +84,9 @@ 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 }))
|
||||
);
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -129,6 +134,10 @@ export default function App() {
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}, []);
|
||||
|
||||
// The server came back as a newer version (any tab, any way it was updated):
|
||||
// load its UI build instead of leaving the user to press F5.
|
||||
useEffect(() => reloadOnNewBuild(), []);
|
||||
|
||||
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
@@ -148,8 +157,22 @@ export default function App() {
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
<Route element={<Layout wsConnected={connected} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<Page>
|
||||
<Dashboard />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="sessions"
|
||||
element={
|
||||
<Page>
|
||||
<History />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="sessions/:id"
|
||||
element={
|
||||
@@ -158,6 +181,15 @@ export default function App() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="snapshot/:token"
|
||||
element={
|
||||
<Page>
|
||||
<SnapshotView />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route
|
||||
path="workflows"
|
||||
|
||||
@@ -70,6 +70,8 @@ import {
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -87,9 +89,11 @@ 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 { useSpin } from "../hooks/useSpin";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -124,6 +128,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
||||
"inactivity",
|
||||
"status_duration",
|
||||
"token_threshold",
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
@@ -139,6 +145,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 +164,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 +187,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 +230,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 +269,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 +296,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) => () => {
|
||||
@@ -288,6 +343,7 @@ export function AlertsNotifications() {
|
||||
setLoadingAlerts(false);
|
||||
}
|
||||
}, [unackedOnly, t]);
|
||||
const [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
@@ -322,10 +378,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 +414,7 @@ export function AlertsNotifications() {
|
||||
|
||||
const openEditRule = (rule: AlertRule) => {
|
||||
setForm(formFromRule(rule));
|
||||
setPreview(null);
|
||||
setEditingId(rule.id);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
@@ -417,7 +499,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 +765,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 +880,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>
|
||||
@@ -825,11 +1019,13 @@ export function AlertsNotifications() {
|
||||
label={t("feed.unackedOnly")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => loadAlerts()}
|
||||
onClick={refreshAlerts}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{unacked > 0 && (
|
||||
<button
|
||||
@@ -927,3 +1123,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file AuthGate.tsx
|
||||
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
|
||||
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
|
||||
* mode — the default — it renders the app and nothing else changes, at once
|
||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
|
||||
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||
|
||||
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
|
||||
|
||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
/** The mode this browser last saw, so a local dashboard paints at once. */
|
||||
const MODE_KEY = "agenthub.authMode";
|
||||
const RETRY_MS = 2000;
|
||||
|
||||
function cachedState(): AuthState | null {
|
||||
try {
|
||||
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// null = still asking the server. A browser that last saw local mode starts
|
||||
// there and is corrected by the answer (or by a 401) if that changed.
|
||||
const [state, setState] = useState<AuthState | null>(cachedState);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
let alive = true;
|
||||
const ask = () => {
|
||||
api.auth
|
||||
.me()
|
||||
.then((next) => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
localStorage.setItem(MODE_KEY, next.mode);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
setState(next);
|
||||
})
|
||||
.catch((err: { status?: number; code?: string }) => {
|
||||
if (!alive) return;
|
||||
// An answer that is not this build's: an older server still running
|
||||
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
||||
// app as before and let it show its own state.
|
||||
if (
|
||||
err?.status === 404 ||
|
||||
err?.code === "EBADRESPONSE" ||
|
||||
(err?.status === 401 && err?.code !== "EAUTH")
|
||||
) {
|
||||
setState({ mode: "local" });
|
||||
return;
|
||||
}
|
||||
// No answer at all (a restart, a proxy's 502): ask again rather than
|
||||
// guess — "local" would hide a server-mode dashboard's login page.
|
||||
timer = window.setTimeout(ask, RETRY_MS);
|
||||
});
|
||||
};
|
||||
ask();
|
||||
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||
const onRequired = () => setState({ mode: "server", user: null });
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
}
|
||||
const logout = async () => {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onLoggedIn((await api.auth.login(username, password)).user);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
|
||||
setPassword("");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
|
||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||
<span>{t("auth.password")}</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="btn-primary w-full" disabled={busy}>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -85,14 +85,14 @@ export function ConfirmModal({
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
<div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
|
||||
{destructive && (
|
||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -46,7 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./Sagi/Sagi`
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
||||
@@ -76,9 +81,29 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Sagi } from "./Sagi/Sagi";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
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]);
|
||||
@@ -136,7 +171,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Sagi />
|
||||
<SagiMount />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -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})`,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BellRing } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||
import { workspaceHref } from "./SessionRowMenu";
|
||||
import type { Session } from "../lib/types";
|
||||
|
||||
@@ -32,6 +33,9 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||
}, []);
|
||||
// The live panes (for the links) move only when a lane's run columns do.
|
||||
const loadRuns = useCallback(() => {
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
@@ -40,11 +44,14 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
loadRuns();
|
||||
}, [load, loadRuns]);
|
||||
useLiveRefresh(load, {
|
||||
intervalMs: 15000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
|
||||
useLiveRefresh(loadRuns, { intervalMs: 15000, match: runChanged });
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file Sagi.tsx
|
||||
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
||||
* @description Floating mascot companion shell. Mounted once by `SagiMount` (next
|
||||
* to UpdateNotifier in Layout), which loads this chunk only while the mascot is
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
@@ -81,6 +84,7 @@ import { SpeechBubble } from "./SpeechBubble";
|
||||
import { SagiPanel } from "./SagiPanel";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -195,24 +199,29 @@ function SagiFlyout({
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders nothing — and runs no hook below — while the mascot is disabled. */
|
||||
export function Sagi() {
|
||||
return useSagiEnabled() ? <SagiBody /> : null;
|
||||
}
|
||||
|
||||
function SagiBody() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
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();
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
// Keep enabled, the pet and its size in sync with Settings / other tabs.
|
||||
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||
// the `Sagi` gate's: it unmounts this body when the mascot is turned off).
|
||||
useEffect(
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setEnabled(sagiPrefs.getEnabled());
|
||||
setPet(sagiPrefs.getPet());
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
@@ -222,7 +231,6 @@ export function Sagi() {
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
||||
@@ -234,7 +242,7 @@ export function Sagi() {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [enabled]);
|
||||
}, []);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
@@ -278,8 +286,6 @@ export function Sagi() {
|
||||
[brain, navigate, t]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -331,6 +337,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 +366,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.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
interface SagiAvatarProps {
|
||||
@@ -76,10 +76,21 @@ if (typeof window !== "undefined") {
|
||||
});
|
||||
}
|
||||
|
||||
export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps) {
|
||||
// The pupil offset is written as CSS variables on the root, never as state, so
|
||||
// following the cursor costs no React render.
|
||||
function setPupil(el: SVGSVGElement | null, x: number, y: number) {
|
||||
el?.style.setProperty("--sagi-px", `${x}px`);
|
||||
el?.style.setProperty("--sagi-py", `${y}px`);
|
||||
}
|
||||
|
||||
// memo: Sagi re-renders on every brain tick; the avatar only on mood/size/motion.
|
||||
export const SagiAvatar = memo(function SagiAvatar({
|
||||
mood,
|
||||
reducedMotion,
|
||||
size = 68,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const [pupil, setPupil] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
@@ -89,7 +100,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tracking) {
|
||||
setPupil({ x: 0, y: 0 });
|
||||
setPupil(rootRef.current, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +116,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
// Normalize then clamp to the eye socket range.
|
||||
const nx = (dx / dist) * Math.min(1, dist / 240);
|
||||
const ny = (dy / dist) * Math.min(1, dist / 240);
|
||||
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
|
||||
setPupil(el, nx * MAX_PUPIL_SHIFT, ny * MAX_PUPIL_SHIFT);
|
||||
};
|
||||
|
||||
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
||||
@@ -202,7 +213,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-left">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -226,7 +237,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-right">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -792,4 +803,4 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file SagiMount.tsx
|
||||
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||
* WebSocket reducer.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||
|
||||
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||
export function useSagiEnabled(): boolean {
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function SagiMount() {
|
||||
if (!useSagiEnabled()) return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Sagi />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,10 @@ export function useSagiBrain(): SagiBrain {
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") setTick(Date.now());
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -76,22 +76,27 @@ 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. */
|
||||
const REFRESH_THROTTLE_MS = 1000;
|
||||
const REFRESH_THROTTLE_MS = 5000;
|
||||
|
||||
/** Compact tile used in the top stat row. */
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
help,
|
||||
icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
hint?: string;
|
||||
/** Plain-language explanation, shown as the label's tooltip. */
|
||||
help?: string;
|
||||
icon: React.ReactNode;
|
||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||
}) {
|
||||
@@ -118,9 +123,14 @@ function StatTile({
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
|
||||
title={help}
|
||||
>
|
||||
<span className={iconTone}>{icon}</span>
|
||||
{label}
|
||||
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
||||
{value}
|
||||
@@ -160,18 +170,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 +209,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 +218,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 (
|
||||
@@ -305,6 +318,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||
<StatTile
|
||||
label={t("overview.events")}
|
||||
help={t("common:insights.help.events")}
|
||||
value={stats.total_events.toLocaleString()}
|
||||
hint={
|
||||
eventRate !== null && eventRate > 0
|
||||
@@ -317,6 +331,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
/>
|
||||
<StatTile
|
||||
label={t("overview.toolCalls")}
|
||||
help={t("common:insights.help.toolCalls")}
|
||||
value={toolCallCount.toLocaleString()}
|
||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||
icon={<Wrench className="w-3 h-3" />}
|
||||
@@ -324,6 +339,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
/>
|
||||
<StatTile
|
||||
label={t("overview.subagents")}
|
||||
help={t("common:insights.help.subagents")}
|
||||
value={stats.agents.subagent.toLocaleString()}
|
||||
hint={
|
||||
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
||||
@@ -333,12 +349,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
/>
|
||||
<StatTile
|
||||
label={t("overview.compactions")}
|
||||
help={t("common:insights.help.compactions")}
|
||||
value={stats.agents.compaction.toLocaleString()}
|
||||
icon={<Layers className="w-3 h-3" />}
|
||||
tone="blue"
|
||||
/>
|
||||
<StatTile
|
||||
label={t("overview.errors")}
|
||||
help={t("common:insights.help.errors")}
|
||||
value={stats.error_count.toLocaleString()}
|
||||
icon={<AlertTriangle className="w-3 h-3" />}
|
||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||
@@ -473,7 +491,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
{totalTokens > 0 && (
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<h3
|
||||
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
|
||||
title={t("common:insights.help.tokenFlow")}
|
||||
>
|
||||
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||
{t("overview.tokenFlow")}
|
||||
</h3>
|
||||
|
||||
@@ -69,9 +69,11 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "./AuthGate";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
@@ -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,8 +186,11 @@ export function Sidebar({
|
||||
onConnectionOpenChange,
|
||||
onUpdateAvailableChange,
|
||||
drawer,
|
||||
width = 240,
|
||||
resizing = false,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
@@ -423,8 +432,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">
|
||||
@@ -524,6 +534,22 @@ export function Sidebar({
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
</NavLink>
|
||||
{auth.state.mode === "server" && auth.state.user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${t("common:auth.signedInAs", {
|
||||
name: auth.state.user.username,
|
||||
role: auth.state.user.role,
|
||||
})} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,8 @@ interface StatCardProps {
|
||||
loading?: boolean;
|
||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||
to?: string;
|
||||
/** Plain-language explanation of the metric, shown as the label's tooltip. */
|
||||
help?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,11 +94,15 @@ export function StatCard({
|
||||
raw,
|
||||
loading = false,
|
||||
to,
|
||||
help,
|
||||
}: StatCardProps) {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||
<span
|
||||
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
|
||||
title={help}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||
|
||||
@@ -23,13 +23,14 @@
|
||||
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 { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { CostResult, ToolQuota } from "../lib/types";
|
||||
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 { useSpin } from "../hooks/useSpin";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
@@ -121,6 +122,7 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
@@ -208,7 +210,7 @@ function QuotaPanel({
|
||||
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"
|
||||
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
>
|
||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||
{groups.map(([id, list]) => (
|
||||
@@ -264,15 +266,15 @@ function QuotaChips() {
|
||||
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));
|
||||
}, []);
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined),
|
||||
[]
|
||||
);
|
||||
const [refreshing, refresh] = useSpin(() => load(true));
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
@@ -281,7 +283,7 @@ function QuotaChips() {
|
||||
type="button"
|
||||
data-testid="status-bar-quota"
|
||||
data-tip-off
|
||||
onClick={() => load(true)}
|
||||
onClick={refresh}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
@@ -313,7 +315,7 @@ function QuotaChips() {
|
||||
tools={tools}
|
||||
onEnter={show}
|
||||
onLeave={hide}
|
||||
onRefresh={() => load(true)}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
@@ -342,6 +344,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;
|
||||
@@ -381,6 +385,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 })
|
||||
@@ -470,6 +481,20 @@ 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>
|
||||
);
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
* - Background re-check via `api.updates.check()` every hour
|
||||
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
||||
* - No client-side background re-check: the server checks on its own
|
||||
* schedule and broadcasts `update_status`; "Check now" stays manual.
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
@@ -85,15 +85,13 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
|
||||
/** How often to silently re-check for updates in the background. */
|
||||
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||
@@ -171,19 +169,6 @@ export function UpdateNotifier() {
|
||||
});
|
||||
}, [syncFromPayload]);
|
||||
|
||||
// Background re-check every hour, on top of the initial mount fetch and the
|
||||
// manual "Check now" button — so a long-lived tab notices an update without
|
||||
// the user having to click anything.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
api.updates
|
||||
.check()
|
||||
.then(syncFromPayload)
|
||||
.catch(() => {});
|
||||
}, AUTO_CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [syncFromPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit "Check for updates" undoes both kinds of close.
|
||||
const handler = () => {
|
||||
@@ -264,22 +249,41 @@ export function UpdateNotifier() {
|
||||
* stretch, so polling naively would get a 200 from the process we are
|
||||
* waiting to replace and reload onto the same old bundle.
|
||||
*/
|
||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
||||
let sawDown = !requireDownFirst;
|
||||
const attempt = () => {
|
||||
api.updates
|
||||
.status()
|
||||
.then(() => {
|
||||
if (sawDown) window.location.reload();
|
||||
else setTimeout(attempt, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
sawDown = true;
|
||||
setTimeout(attempt, 1500);
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 1500);
|
||||
}, []);
|
||||
const pollUntilBack = useCallback(
|
||||
(requireDownFirst = false, fromVersion: string | null = null) => {
|
||||
let sawDown = !requireDownFirst;
|
||||
// Its own verdicts only: an older plugin-update.json is still on disk
|
||||
// until this chain writes one (5 s slack for a clock a little behind).
|
||||
const since = Date.now() - 5000;
|
||||
const attempt = () => {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
// never goes down, so waiting for that spun "Restarting…" forever.
|
||||
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
|
||||
syncFromPayload(s);
|
||||
setRestarting(false);
|
||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||
return;
|
||||
}
|
||||
// A new version answering is back too, even if a poll never
|
||||
// landed in the short gap between the old server and the new one.
|
||||
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
|
||||
if (sawDown || moved) window.location.reload();
|
||||
else setTimeout(attempt, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
sawDown = true;
|
||||
setTimeout(attempt, 1500);
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 1500);
|
||||
},
|
||||
[syncFromPayload, t]
|
||||
);
|
||||
|
||||
const applyNow = async () => {
|
||||
if (applying || restarting) return;
|
||||
@@ -292,7 +296,7 @@ export function UpdateNotifier() {
|
||||
setRestarting(true);
|
||||
// mode "plugin": the old server lingers while the chain installs, so
|
||||
// only treat a reachable server as "back" after it has gone away once.
|
||||
pollUntilBack(result.mode === "plugin");
|
||||
pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
@@ -330,7 +334,7 @@ export function UpdateNotifier() {
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
||||
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
@@ -370,7 +374,7 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||
</p>
|
||||
|
||||
@@ -6,14 +6,23 @@
|
||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||
* live tab is opened from its session row. Live status comes from the shared
|
||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves and handed over by the Workspace page's own
|
||||
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals so the row menu works from any page. Which rows are
|
||||
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
@@ -21,6 +30,7 @@ import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||
import {
|
||||
insertionIndex,
|
||||
moveBefore,
|
||||
@@ -33,8 +43,15 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||
|
||||
// The sidebar is in the entry chunk; its two modals (and the run form the add
|
||||
// modal pulls in) load when first opened.
|
||||
const AddLaneModal = lazy(() =>
|
||||
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
||||
);
|
||||
const DestructiveLaneModal = lazy(() =>
|
||||
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
||||
);
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||
@@ -72,8 +89,12 @@ export function WorkspaceTree() {
|
||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||
// status or session moves, so that is when the live-pane list is re-read;
|
||||
// the Workspace page also hands over every run list it polls (a tab in
|
||||
// slots 1–3 moves no lane column).
|
||||
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
|
||||
// slots 1–3 moves no lane column). Throttled: with two sessions in one
|
||||
// workspace these columns flip on every hook, and each flip was a GET.
|
||||
const runKey = useThrottledValue(
|
||||
lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(","),
|
||||
3000
|
||||
);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.run
|
||||
@@ -349,28 +370,32 @@ export function WorkspaceTree() {
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
/>
|
||||
)}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
action={destructive.action}
|
||||
open
|
||||
onClose={() => setDestructive(null)}
|
||||
onConfirm={(body) => void confirmDestructive(body)}
|
||||
/>
|
||||
)}
|
||||
<AddLaneModal
|
||||
open={addOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
prefill={addPrefill}
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
action={destructive.action}
|
||||
open
|
||||
onClose={() => setDestructive(null)}
|
||||
onConfirm={(body) => void confirmDestructive(body)}
|
||||
/>
|
||||
)}
|
||||
{addOpen && (
|
||||
<AddLaneModal
|
||||
open
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
prefill={addPrefill}
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,100 @@
|
||||
/**
|
||||
* @file AuthGate.test.tsx
|
||||
* @description The login gate: local mode renders the app untouched, server
|
||||
* mode shows the login page until a login succeeds, a wrong password says so,
|
||||
* and a later 401 `EAUTH` brings the login page back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { AuthGate } from "../AuthGate";
|
||||
|
||||
const user = { id: 1, username: "mia", role: "member" };
|
||||
|
||||
beforeEach(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("AuthGate", () => {
|
||||
it("renders the app in local mode", async () => {
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("paints at once in a browser that last saw local mode", () => {
|
||||
localStorage.setItem("agenthub.authMode", "local");
|
||||
me.mockReturnValue(new Promise(() => {}));
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
expect(screen.getByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks again instead of guessing when the server does not answer", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
me.mockRejectedValueOnce(new Error("502"));
|
||||
me.mockResolvedValue({ mode: "server", user: null });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2100);
|
||||
});
|
||||
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the app when an older server or a dashboard token answers instead", async () => {
|
||||
for (const err of [
|
||||
{ status: 404 },
|
||||
{ status: 200, code: "EBADRESPONSE" },
|
||||
{ status: 401, code: "EUNAUTHORIZED" },
|
||||
]) {
|
||||
me.mockReset();
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for a login in server mode and opens the app after one", async () => {
|
||||
me.mockResolvedValue({ mode: "server", user: null });
|
||||
login.mockRejectedValueOnce(Object.assign(new Error("x"), { code: "EBADLOGIN" }));
|
||||
login.mockResolvedValueOnce({ user });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
const name = await screen.findByLabelText(/username/i);
|
||||
const pass = screen.getByLabelText(/password/i);
|
||||
await userEvent.type(name, "mia");
|
||||
await userEvent.type(pass, "nope");
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
|
||||
await userEvent.type(pass, "member-pass-1");
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("agenthub:auth-required"));
|
||||
});
|
||||
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,21 @@ vi.mock("../../lib/api", () => ({
|
||||
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],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -142,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();
|
||||
@@ -221,7 +244,11 @@ describe("StatusBar", () => {
|
||||
await user.click(refresh);
|
||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||
await act(async () => answer(same));
|
||||
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
|
||||
// The spin outlasts an instant answer (a throttled refresh is served from
|
||||
// cache), so the label returns after the minimum spin.
|
||||
await waitFor(() =>
|
||||
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());
|
||||
|
||||
@@ -222,6 +222,40 @@ describe("UpdateNotifier — Update now", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — plugin chain verdict", () => {
|
||||
it("stops Restarting when the chain fails and the old server stays up", async () => {
|
||||
const PLUGIN: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
};
|
||||
statusMock.mockResolvedValue(PLUGIN);
|
||||
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||
|
||||
// The old server answers again, now carrying this chain's failed verdict.
|
||||
statusMock.mockResolvedValue({
|
||||
...PLUGIN,
|
||||
last_plugin_update: {
|
||||
ok: false,
|
||||
step: "plugin_update",
|
||||
error: "boom",
|
||||
finished_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — closing vs skipping", () => {
|
||||
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
||||
const { unmount } = render(<UpdateNotifier />);
|
||||
|
||||
@@ -88,7 +88,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
iconUrl: () => "",
|
||||
},
|
||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
||||
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
|
||||
aria-modal="true"
|
||||
aria-labelledby="cc-confirm-delete-title"
|
||||
aria-describedby="cc-confirm-delete-body"
|
||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
|
||||
{t("edit.confirmDelete")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
||||
{t("edit.confirmDeleteBody")}
|
||||
</p>
|
||||
|
||||
@@ -59,6 +59,7 @@ import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
|
||||
@@ -106,7 +107,6 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
const genRef = useRef(0);
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
@@ -242,6 +242,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// also poll below to catch what WS misses.
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab: the visibility handler below catches up once on show.
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
fetchNewMessages();
|
||||
return;
|
||||
@@ -303,14 +305,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await fetchNewMessages();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [fetchNewMessages]);
|
||||
const [refreshing, refresh] = useSpin(fetchNewMessages);
|
||||
|
||||
// Scroll-up to load history
|
||||
const loadHistory = useCallback(async () => {
|
||||
|
||||
@@ -57,7 +57,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
RUNTIME_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
@@ -88,7 +91,10 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
LOCKS_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
|
||||
@@ -81,6 +81,7 @@ import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||
import {
|
||||
LaneDiffView,
|
||||
@@ -248,6 +249,7 @@ function TreeNodes({
|
||||
type="button"
|
||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||
data-tree-kept={isKept || undefined}
|
||||
data-tree-ignored={e.ignored || undefined}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
@@ -255,11 +257,13 @@ function TreeNodes({
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||
isKept
|
||||
? "bg-kept/10 text-kept"
|
||||
: mark
|
||||
? mark.tone
|
||||
: e.changed
|
||||
? "text-status-warning"
|
||||
: "text-fg-primary"
|
||||
: e.ignored
|
||||
? "text-kept"
|
||||
: mark
|
||||
? mark.tone
|
||||
: e.changed
|
||||
? "text-status-warning"
|
||||
: "text-fg-primary"
|
||||
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
||||
>
|
||||
{e.dir ? (
|
||||
@@ -495,7 +499,9 @@ function ChangeRow({
|
||||
return (
|
||||
<li
|
||||
className="group flex items-baseline gap-1"
|
||||
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
|
||||
// A file has no chevron: reserve its 1rem so the letter lines up
|
||||
// with a sibling folder's icon, one step inside the parent folder.
|
||||
style={{ paddingLeft: `${depth + 1}rem` }}
|
||||
onContextMenu={(e) => onMenu(e, f)}
|
||||
>
|
||||
<button
|
||||
@@ -571,10 +577,7 @@ function ChangeTreeNodes({
|
||||
const open = !collapsed.has(key);
|
||||
return (
|
||||
<li key={d.path}>
|
||||
<div
|
||||
className="group flex items-center gap-1"
|
||||
style={{ paddingLeft: `${depth * 0.9}rem` }}
|
||||
>
|
||||
<div className="group flex items-center gap-1" style={{ paddingLeft: `${depth}rem` }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
@@ -878,7 +881,10 @@ export function LaneChangesPanel({
|
||||
void load();
|
||||
// Only the cheap shape polls; see the file header.
|
||||
if (mode !== "overview") return;
|
||||
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
|
||||
const timer = window.setInterval(
|
||||
() => document.visibilityState === "visible" && void load(),
|
||||
OVERVIEW_REFRESH_MS
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, mode]);
|
||||
|
||||
@@ -1416,8 +1422,9 @@ export function LaneChangesPanel({
|
||||
setTreeChildren({});
|
||||
setExpandedDirs(new Set());
|
||||
setReread((n) => n + 1);
|
||||
void load();
|
||||
return load();
|
||||
};
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
const toggleCollapsed = (path: string) =>
|
||||
setCollapsed((prev) => {
|
||||
@@ -1604,12 +1611,12 @@ export function LaneChangesPanel({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={rereadAll}
|
||||
onClick={runReread}
|
||||
aria-label={t("changes.refresh")}
|
||||
title={t("changes.refresh")}
|
||||
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading || rereading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,7 +65,10 @@ export function useLaneGitFacts(
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
GIT_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
|
||||
@@ -108,7 +108,7 @@ export function SessionReplay({
|
||||
? { runId: holder.id, laneId: lane.id, held: true }
|
||||
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
onResumed(r.runId, r.laneId);
|
||||
// Held already: the prompt reached nothing yet — paste it there.
|
||||
|
||||
@@ -26,15 +26,20 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
|
||||
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
||||
* same path-boundary rule the server binds hook sessions with. */
|
||||
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
|
||||
let best: Lane | null = null;
|
||||
const target = strip(cwd);
|
||||
for (const l of lanes) {
|
||||
if (l.tool !== tool) continue;
|
||||
const root = l.cwd.replace(/\/+$/, "");
|
||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
||||
const root = strip(l.cwd);
|
||||
if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
|
||||
continue;
|
||||
if (!best || root.length > strip(best.cwd).length) best = l;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -74,7 +79,7 @@ export function SessionResumeButton({
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
if (!lane || runs === null) return null;
|
||||
const run = liveRunFor(lane, runs, session.id);
|
||||
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
||||
const same = (a: string, b: string) => strip(a) === strip(b);
|
||||
// Its own worktree counts too: the server resumes it there (`claude
|
||||
// --worktree` sessions), and refuses one whose worktree is gone.
|
||||
const resumable =
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* `ConsoleTabs.tsx`). Inside
|
||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||||
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* router's models, plus Effort); under it the quieter choices — AI Router
|
||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
@@ -58,7 +58,7 @@ import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
@@ -125,27 +125,43 @@ 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();
|
||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
||||
const routerCapable = entry?.routerCapable ?? (claude || codex);
|
||||
const [proxy, setProxy] = useSessionProxy();
|
||||
const proxies = useAiProxies();
|
||||
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||
const profile = routerCapable
|
||||
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
|
||||
const profile = proxyCapable
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||
: null;
|
||||
const routerModels: ModelChoice[] | undefined = profile
|
||||
const proxyModels: ModelChoice[] | undefined = profile
|
||||
? [
|
||||
...new Set(
|
||||
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
||||
Boolean
|
||||
(m): m is string => !!m
|
||||
)
|
||||
),
|
||||
].map((id) => ({ id, label: id }))
|
||||
: undefined;
|
||||
// The tool's default model (also when the catalog arrives after mount); on
|
||||
// a router, the router's own default. A new tool or router drops the pick.
|
||||
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
|
||||
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
||||
const [model, setModel] = useState(defaultModel);
|
||||
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
||||
@@ -186,6 +202,12 @@ function HomeBody({
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [ctxDrag, setCtxDrag] = useState(false);
|
||||
const addFiles = async (picked: File[]) => {
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
};
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
@@ -303,7 +325,7 @@ function HomeBody({
|
||||
// Only a different tool is named: the workspace's own keeps starting
|
||||
// even after it was disabled in Settings.
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...routerArg(routerCapable, router),
|
||||
...proxyArg(proxyCapable, proxy),
|
||||
...(hasModel && model ? { model } : {}),
|
||||
...(claude ? { permissionMode: mode } : {}),
|
||||
...(codex ? { sandbox, approval } : {}),
|
||||
@@ -312,7 +334,7 @@ function HomeBody({
|
||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||
});
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
@@ -517,7 +539,7 @@ function HomeBody({
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
tool={tool}
|
||||
choices={routerModels}
|
||||
choices={proxyModels}
|
||||
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
||||
/>
|
||||
)}
|
||||
@@ -540,12 +562,12 @@ function HomeBody({
|
||||
data-testid="workspace-home-options"
|
||||
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
||||
>
|
||||
<RouterPicker
|
||||
<ProxyPicker
|
||||
variant="ghost"
|
||||
tool={tool}
|
||||
routers={routers}
|
||||
value={router}
|
||||
onChange={setRouter}
|
||||
proxies={proxies}
|
||||
value={proxy}
|
||||
onChange={setProxy}
|
||||
/>
|
||||
{claude && (
|
||||
<Select<PermissionMode>
|
||||
@@ -823,7 +845,26 @@ function HomeBody({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<section
|
||||
data-testid="workspace-context-files"
|
||||
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||
// Files dragged in from the OS are added like the + button's pick.
|
||||
onDragOver={(e) => {
|
||||
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setCtxDrag(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
setCtxDrag(false);
|
||||
if (!view || e.dataTransfer.files.length === 0) return;
|
||||
e.preventDefault();
|
||||
void addFiles([...e.dataTransfer.files]);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||
<button
|
||||
@@ -842,12 +883,10 @@ function HomeBody({
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={async (e) => {
|
||||
onChange={(e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
void addFiles(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { timeAgo } from "../../lib/format";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
|
||||
@@ -66,9 +67,10 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
||||
/** The name of the git worktree a session ran in (`claude --worktree` puts it
|
||||
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
|
||||
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
||||
const strip = (p: string) => p.replace(/\/+$/, "");
|
||||
// Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
|
||||
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
|
||||
return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||
@@ -266,10 +268,16 @@ export function WorkspaceSessionList({
|
||||
);
|
||||
// The lane's own session/status/needs-you move on every hook, which is when
|
||||
// a session can appear or change state, and a tab opening or getting its
|
||||
// session is too — re-read then, not on a timer.
|
||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
|
||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||
.join(",")}`;
|
||||
// session is too — re-read then, not on a timer. Throttled to one re-read per
|
||||
// 3 s: two sessions in one workspace flip these on every hook. A rename or an
|
||||
// archive (`version`) re-reads at once.
|
||||
const hookKey = useThrottledValue(
|
||||
`${lane.session_id}:${lane.status}:${lane.needs_action}:${laneRuns
|
||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||
.join(",")}`,
|
||||
3000
|
||||
);
|
||||
const refreshKey = `${hookKey}:${version}`;
|
||||
// A Terminal tab closing is when one joins the history.
|
||||
const shellKey = liveShells.map((r) => r.id).join(",");
|
||||
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
profileInit: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiProxies: { list: vi.fn() },
|
||||
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
@@ -50,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
proxyCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
modelFlag: false,
|
||||
@@ -62,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
const TOOLS: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
|
||||
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
|
||||
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
|
||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
@@ -158,7 +158,7 @@ beforeEach(() => {
|
||||
.mockReset()
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiProxies.list).mockReset();
|
||||
});
|
||||
|
||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
@@ -639,12 +639,12 @@ describe("AddLaneModal — create/update readme", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — no router here", () => {
|
||||
it("asks for no AI Router: a new session picks its own", async () => {
|
||||
describe("AddLaneModal — no proxy here", () => {
|
||||
it("asks for no AI Proxy: a new session picks its own", async () => {
|
||||
renderModal({});
|
||||
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
|
||||
expect(api.aiRouters.list).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: {
|
||||
aiProxies: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
run: {
|
||||
@@ -45,10 +45,10 @@ beforeEach(() => {
|
||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
// Only the built-in profile by default: a user who never opened the AI Router
|
||||
// Only the built-in profile by default: a user who never opened the AI Proxy
|
||||
// tab has nothing to switch between, so the row stays out of every other test.
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
||||
vi.mocked(api.aiProxies.list).mockReset();
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
|
||||
});
|
||||
vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file WorkspaceHome.test.tsx
|
||||
* @description The workspace home: a new session starts in the first free
|
||||
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
||||
* default), AI and Model in the box and AI Router / Permission / Worktree
|
||||
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
|
||||
* default), AI and Model in the box and AI Proxy / Permission / Worktree
|
||||
* under it (the proxy's models on a proxy), all-busy is refused, Recents open a live pane or the
|
||||
* transcript (never a resume), instructions save, context files upload and
|
||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { Lane, Session } from "../../../lib/types";
|
||||
@@ -117,7 +117,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
},
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
|
||||
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -193,7 +193,9 @@ describe("freeSlot / paneRunId", () => {
|
||||
it("picks the first slot no live run of this lane holds", () => {
|
||||
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,7 +224,24 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
|
||||
it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
|
||||
first.unmount();
|
||||
const other = renderHome(lane({ id: 2 }));
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
|
||||
other.unmount();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const labels = (id: string) =>
|
||||
@@ -233,7 +252,7 @@ describe("WorkspaceHome", () => {
|
||||
].map((x) => x.getAttribute("aria-label"));
|
||||
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
||||
expect(labels("workspace-home-options")).toEqual([
|
||||
"AI Router",
|
||||
"AI Proxy",
|
||||
"Permission mode",
|
||||
expect.stringContaining("own git worktree"),
|
||||
]);
|
||||
@@ -244,8 +263,8 @@ describe("WorkspaceHome", () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
|
||||
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
|
||||
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
|
||||
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
|
||||
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
||||
].map((x) => ({ installed: true, enabled: true, ...x })),
|
||||
} as never);
|
||||
@@ -270,9 +289,9 @@ describe("WorkspaceHome", () => {
|
||||
__resetAiTools();
|
||||
});
|
||||
|
||||
it("offers the router's models on a router, and sends the router", async () => {
|
||||
localStorage.setItem("agenthub.session.router", "glm");
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
|
||||
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
|
||||
localStorage.setItem("agenthub.session.proxy", "glm");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ name: "native", label: "Native", builtin: true, models: {} },
|
||||
{
|
||||
@@ -297,7 +316,7 @@ describe("WorkspaceHome", () => {
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ router: "glm" })
|
||||
expect.objectContaining({ proxy: "glm" })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -435,6 +454,13 @@ describe("WorkspaceHome", () => {
|
||||
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
||||
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
||||
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
|
||||
const a = new File(["a"], "a.csv", { type: "text/csv" });
|
||||
const b = new File(["b"], "b.log", { type: "" });
|
||||
fireEvent.drop(screen.getByTestId("workspace-context-files"), {
|
||||
dataTransfer: { types: ["Files"], files: [a, b] },
|
||||
});
|
||||
await waitFor(() => expect(api.lanes.addFile).toHaveBeenCalledWith(1, b));
|
||||
expect(api.lanes.addFile).toHaveBeenCalledWith(1, a);
|
||||
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
|
||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file Tests that a session finds its workspace and its `claude --worktree`
|
||||
* name by folder on Windows paths (`D:\…`) as well as POSIX ones — the
|
||||
* transcript page's Resume/Fork/Open pane hid on Windows when they matched
|
||||
* on "/" only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Lane, Session } from "../../../lib/types";
|
||||
import { owningLane } from "../SessionResumeButton";
|
||||
import { sessionWorktreeName } from "../WorkspaceSessionList";
|
||||
|
||||
const lane = (id: number, cwd: string) => ({ id, cwd, tool: "claude" }) as Lane;
|
||||
|
||||
describe("session paths", () => {
|
||||
it("finds the deepest owning workspace with either separator", () => {
|
||||
const lanes = [lane(1, "D:\\Project"), lane(2, "D:\\Project\\Hobby\\"), lane(3, "/home/u/app")];
|
||||
expect(owningLane("D:\\Project\\Hobby\\tri-vi-portfolio", "claude", lanes)?.id).toBe(2);
|
||||
expect(owningLane("D:\\Project\\Hobby", "claude", lanes)?.id).toBe(2);
|
||||
expect(owningLane("D:\\ProjectX", "claude", lanes)).toBeNull();
|
||||
expect(owningLane("/home/u/app/src", "claude", lanes)?.id).toBe(3);
|
||||
});
|
||||
|
||||
it("reads a worktree name from a Windows or POSIX session folder", () => {
|
||||
const at = (cwd: string) => ({ cwd }) as Session;
|
||||
expect(
|
||||
sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\.claude\\worktrees\\fix-a"))
|
||||
).toBe("fix-a");
|
||||
expect(sessionWorktreeName(lane(1, "/r"), at("/r/.claude/worktrees/fix-b/"))).toBe("fix-b");
|
||||
expect(sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
* or an installed, enabled AI tool started bare on its default model, no
|
||||
* prompt (a prompt, router or worktree is still the workspace home's prompt
|
||||
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||
@@ -22,7 +22,10 @@
|
||||
* a `+` item) brings it into the tab on screen, split to the right, and a
|
||||
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
||||
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
||||
* bar takes that pane out into a tab of its own there. A right-click on a tab
|
||||
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||
* of another tab, a tab (or a pane header) joins that tab instead
|
||||
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -53,8 +56,10 @@ import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./Pan
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
detachSlot,
|
||||
groupOf,
|
||||
joinGroup,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
} from "../../lib/paneLayout";
|
||||
@@ -207,6 +212,10 @@ export function ConsoleTabs({
|
||||
// point among the drawn tabs, from the pointer against their middles.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropAt, setDropAt] = useState<number | null>(null);
|
||||
// The drawn tab a drop would join (the pointer on its middle half), and the
|
||||
// slots a drag from this bar carries — unknown for a pane header (one slot).
|
||||
const [joinAt, setJoinAt] = useState<number | null>(null);
|
||||
const dragged = useRef<number[]>([]);
|
||||
const isBarDrag = (e: DragEvent) => {
|
||||
const types = e.dataTransfer?.types;
|
||||
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
||||
@@ -221,31 +230,70 @@ export function ConsoleTabs({
|
||||
x
|
||||
);
|
||||
};
|
||||
const joinIndexAt = (x: number) => {
|
||||
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
|
||||
const i = els.findIndex((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return x >= b.left + b.width / 4 && x <= b.right - b.width / 4;
|
||||
});
|
||||
const tab = tabs[i];
|
||||
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
|
||||
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
|
||||
};
|
||||
const endDrag = () => {
|
||||
dragged.current = [];
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
};
|
||||
const dragHandlers = {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const at = dropIndexAt(e.clientX);
|
||||
const join = joinIndexAt(e.clientX);
|
||||
const at = join === null ? dropIndexAt(e.clientX) : null;
|
||||
setJoinAt((d) => (d === join ? d : join));
|
||||
setDropAt((d) => (d === at ? d : at));
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
||||
},
|
||||
onDragEnd: () => setDropAt(null),
|
||||
onDrop: (e: DragEvent) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
},
|
||||
onDragEnd: endDrag,
|
||||
onDrop: (e: DragEvent) => {
|
||||
const join = joinIndexAt(e.clientX);
|
||||
endDrag();
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||
const target = join === null ? undefined : tabs[join];
|
||||
// A pane header dropped on its OWN tab is a detach, not a join: a pane
|
||||
// drag carries no slots during dragover, so joinIndexAt cannot skip it.
|
||||
const ownTab =
|
||||
tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index;
|
||||
if (target && !ownTab) {
|
||||
onGroupsChange((g) =>
|
||||
joinGroup(
|
||||
g,
|
||||
tab !== ""
|
||||
? leaves(g.groups[Number(tab)]?.root ?? null)
|
||||
: raw !== ""
|
||||
? [Number(raw)]
|
||||
: [],
|
||||
target.index
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The drawn position, as an index into the groups.
|
||||
const drawn = dropIndexAt(e.clientX);
|
||||
const at = tabs[drawn]?.index ?? groups.groups.length;
|
||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||
if (tab !== "") {
|
||||
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
||||
return;
|
||||
}
|
||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||
if (raw === "") return;
|
||||
const slot = Number(raw);
|
||||
onGroupsChange((g) => {
|
||||
@@ -432,6 +480,7 @@ export function ConsoleTabs({
|
||||
data-slots={tab.key}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragged.current = tab.slots;
|
||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||
else {
|
||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||
@@ -445,7 +494,7 @@ export function ConsoleTabs({
|
||||
active
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
} ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`}
|
||||
>
|
||||
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
||||
<span
|
||||
|
||||
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
|
||||
return [current.groups, update];
|
||||
}
|
||||
|
||||
type Zone = Edge | "center";
|
||||
export type Zone = Edge | "center";
|
||||
const pct = (r: Rect) => ({
|
||||
left: `${r.x * 100}%`,
|
||||
top: `${r.y * 100}%`,
|
||||
width: `${r.w * 100}%`,
|
||||
height: `${r.h * 100}%`,
|
||||
});
|
||||
/** The half (or the whole) of a pane a drop zone would fill. */
|
||||
function zoneRect(r: Rect, zone: Zone): Rect {
|
||||
if (zone === "left") return { ...r, w: r.w / 2 };
|
||||
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
|
||||
if (zone === "top") return { ...r, h: r.h / 2 };
|
||||
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
|
||||
return r;
|
||||
/** What dropping `src` on a pane's zone does: the center swaps, an edge
|
||||
* splits. The drop applies it and the preview draws it, so they agree. */
|
||||
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
|
||||
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
|
||||
}
|
||||
|
||||
/** Where `src` would land, or null for a drop that changes nothing. A pane
|
||||
* already shown leaves its place first, so its target grows before the
|
||||
* split — the target's own half is not where it lands. */
|
||||
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
|
||||
if (src === slot) return null;
|
||||
const next = applyDrop(l, src, slot, zone);
|
||||
if (next === l) return null;
|
||||
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
|
||||
}
|
||||
|
||||
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
||||
@@ -323,7 +330,7 @@ export function PaneGrid({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
|
||||
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
|
||||
const { panes, dividers } = layoutRects(layout.root);
|
||||
const shown = leaves(layout.root);
|
||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
||||
@@ -391,6 +398,8 @@ export function PaneGrid({
|
||||
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
||||
};
|
||||
|
||||
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
|
||||
|
||||
const isPaneDrag = (e: React.DragEvent) =>
|
||||
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
||||
|
||||
@@ -404,7 +413,10 @@ export function PaneGrid({
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
const z = zoneAt(e);
|
||||
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
|
||||
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
|
||||
setDrop((d) =>
|
||||
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
|
||||
);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||
@@ -417,11 +429,7 @@ export function PaneGrid({
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||
onChange((l) =>
|
||||
z.zone === "center"
|
||||
? swapInLayout(l, src, z.slot)
|
||||
: moveInLayout(l, src, z.slot, z.zone as Edge)
|
||||
);
|
||||
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||
}}
|
||||
>
|
||||
{mounted.map((slot) => {
|
||||
@@ -561,11 +569,11 @@ export function PaneGrid({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{drop && rectOf.has(drop.slot) && (
|
||||
{preview && (
|
||||
<div
|
||||
data-testid="pane-drop-preview"
|
||||
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
|
||||
style={pct(preview)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @file ProxyPicker.tsx
|
||||
* @description The AI Proxy a NEW session starts on — a session's choice, not
|
||||
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
|
||||
* Native or one of the saved profiles (`useAiProxies`, which the caller also
|
||||
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||
* for the next session (`agenthub.session.proxy`; the pre-rename
|
||||
* `agenthub.session.router` is still read when that is unset).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AiProxy } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { Select } from "../Select";
|
||||
|
||||
const KEY = "agenthub.session.proxy";
|
||||
const LEGACY_KEY = "agenthub.session.router";
|
||||
|
||||
/** The remembered pick, `"native"` by default. */
|
||||
export function useSessionProxy(): [string, (v: string) => void] {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
|
||||
} catch {
|
||||
return "native";
|
||||
}
|
||||
});
|
||||
const set = (v: string) => {
|
||||
setValue(v);
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage off — the pick still holds for this page */
|
||||
}
|
||||
};
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** The saved profiles (Native included), null until they arrive. */
|
||||
export function useAiProxies(): AiProxy[] | null {
|
||||
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiProxies
|
||||
.list()
|
||||
.then((r) => alive && setProxies(r.items))
|
||||
.catch(() => alive && setProxies([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return proxies;
|
||||
}
|
||||
|
||||
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
|
||||
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
|
||||
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
|
||||
}
|
||||
|
||||
export function ProxyPicker({
|
||||
tool,
|
||||
proxies,
|
||||
value,
|
||||
onChange,
|
||||
variant,
|
||||
}: {
|
||||
variant?: "field" | "ghost";
|
||||
tool: string;
|
||||
/** From `useAiProxies`. */
|
||||
proxies: AiProxy[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const aiTools = useAiTools();
|
||||
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
|
||||
const native = t("proxy.native", { tool: aiTools.label(tool) });
|
||||
|
||||
// A remembered profile that has since been deleted falls back to native,
|
||||
// or the start would be refused for a proxy the user cannot see.
|
||||
useEffect(() => {
|
||||
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
|
||||
}, [proxies, value, onChange]);
|
||||
|
||||
const options =
|
||||
capable && proxies?.length
|
||||
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||
: [{ value: "native", label: native }];
|
||||
return (
|
||||
<div data-testid="proxy-picker">
|
||||
<Select<string>
|
||||
value={capable ? value : "native"}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
disabled={!capable}
|
||||
variant={variant}
|
||||
label={t("proxy.label")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @file RouterPicker.tsx
|
||||
* @description The AI Router a NEW session starts on — a session's choice, not
|
||||
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
|
||||
* Native or one of the saved profiles (`useAiRouters`, which the caller also
|
||||
* reads to list the router's models). A tool that cannot use a router (only
|
||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||
* for the next session (`agenthub.session.router`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AiRouter } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { Select } from "../Select";
|
||||
|
||||
const KEY = "agenthub.session.router";
|
||||
|
||||
/** The remembered pick, `"native"` by default. */
|
||||
export function useSessionRouter(): [string, (v: string) => void] {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(KEY) || "native";
|
||||
} catch {
|
||||
return "native";
|
||||
}
|
||||
});
|
||||
const set = (v: string) => {
|
||||
setValue(v);
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage off — the pick still holds for this page */
|
||||
}
|
||||
};
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** The saved profiles (Native included), null until they arrive. */
|
||||
export function useAiRouters(): AiRouter[] | null {
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => alive && setRouters(r.items))
|
||||
.catch(() => alive && setRouters([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return routers;
|
||||
}
|
||||
|
||||
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
|
||||
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
|
||||
return routerCapable && router && router !== "native" ? { router } : {};
|
||||
}
|
||||
|
||||
export function RouterPicker({
|
||||
tool,
|
||||
routers,
|
||||
value,
|
||||
onChange,
|
||||
variant,
|
||||
}: {
|
||||
variant?: "field" | "ghost";
|
||||
tool: string;
|
||||
/** From `useAiRouters`. */
|
||||
routers: AiRouter[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const aiTools = useAiTools();
|
||||
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
||||
const native = t("router.native", { tool: aiTools.label(tool) });
|
||||
|
||||
// A remembered profile that has since been deleted falls back to native,
|
||||
// or the start would be refused for a router the user cannot see.
|
||||
useEffect(() => {
|
||||
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
|
||||
}, [routers, value, onChange]);
|
||||
|
||||
const options =
|
||||
capable && routers?.length
|
||||
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||
: [{ value: "native", label: native }];
|
||||
return (
|
||||
<div data-testid="router-picker">
|
||||
<Select<string>
|
||||
value={capable ? value : "native"}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
disabled={!capable}
|
||||
variant={variant}
|
||||
label={t("router.label")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||
* newlines, and a pasted or dropped image attached by its saved path
|
||||
* (`POST /api/run/drop-image`);
|
||||
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
* or a fixed one (an AI Router's models), plus Custom…;
|
||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
||||
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
||||
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Home,
|
||||
@@ -47,12 +48,15 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "builtin";
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
// Commands the CLI answers itself, and the skills bundled inside it. They are
|
||||
// typed into the pane like any other text, so they do run — they are just not
|
||||
// files anyone can list.
|
||||
// ponytail: a hand-kept list, so it lags new Claude Code releases; no file or
|
||||
// API lists the built-ins, add names here as they ship.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
@@ -62,6 +66,8 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"usage",
|
||||
"context",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
@@ -69,14 +75,36 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"reload-plugins",
|
||||
"skills",
|
||||
"output-style",
|
||||
"resume",
|
||||
"rewind",
|
||||
"add-dir",
|
||||
"doctor",
|
||||
"export",
|
||||
"login",
|
||||
"logout",
|
||||
"statusline",
|
||||
"theme",
|
||||
"fast",
|
||||
"exit",
|
||||
"code-review",
|
||||
"security-review",
|
||||
"simplify",
|
||||
"loop",
|
||||
"schedule",
|
||||
"fewer-permission-prompts",
|
||||
"update-config",
|
||||
"claude-api",
|
||||
"run",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
plugin: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
@@ -126,8 +154,8 @@ interface PromptEditorProps {
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
|
||||
* them with the text through `promptWithImages`. */
|
||||
/** Files pasted or dropped so far (images shown as thumbnails); the caller
|
||||
* sends them with the text through `promptWithImages`. */
|
||||
images: PromptImage[];
|
||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||
/** Borderless, for a composer box that draws its own frame. */
|
||||
@@ -137,11 +165,12 @@ interface PromptEditorProps {
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
/** An attached image: its path on the server's machine and a local preview. */
|
||||
export type PromptImage = { path: string; url: string; name: string };
|
||||
/** An attached file: its path on the server's machine, and a local preview
|
||||
* `url` when it is an image. */
|
||||
export type PromptImage = { path: string; url?: string; name: string };
|
||||
|
||||
/** The prompt as sent: the text, then each image's saved path — which is how
|
||||
* the CLI is handed an image. */
|
||||
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||
* the CLI is handed an image or a file. */
|
||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -185,8 +214,26 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([c, sk, pl]) => {
|
||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||
// installed at both user and project scope is listed once.
|
||||
const fromPlugins = pl.plugins
|
||||
.filter((p) => p.enabled !== false && p.contributes)
|
||||
.flatMap((p) =>
|
||||
[...p.contributes!.items.commands, ...p.contributes!.items.skills].map(
|
||||
(it): SlashCommand => ({
|
||||
name: `${p.name}:${it.name}`,
|
||||
source: "plugin",
|
||||
description: it.description || undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i);
|
||||
if (live)
|
||||
setCommands(
|
||||
[...c.items, ...sk.items].map(toCmd).concat(fromPlugins, BUILTIN_SLASH_COMMANDS)
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
@@ -264,9 +311,9 @@ export function PromptEditor({
|
||||
});
|
||||
};
|
||||
|
||||
// A pasted or dropped image is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail; its path is
|
||||
// added to the prompt only when it is sent (promptWithImages).
|
||||
// A pasted or dropped file is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
@@ -275,15 +322,15 @@ export function PromptEditor({
|
||||
busyRef.current = onBusyChange;
|
||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropImage(f)))
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
path: saved[i]!.path,
|
||||
url: URL.createObjectURL(f),
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
@@ -292,7 +339,7 @@ export function PromptEditor({
|
||||
return true;
|
||||
};
|
||||
const removeImage = (img: PromptImage) => {
|
||||
URL.revokeObjectURL(img.url);
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
};
|
||||
useEffect(() => {
|
||||
@@ -349,17 +396,27 @@ export function PromptEditor({
|
||||
{images.length > 0 && (
|
||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||
{images.map((img) => (
|
||||
<div key={img.url} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
<div key={img.path} className="group relative">
|
||||
{img.url ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
title={img.path}
|
||||
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{img.name}</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("drop.remove")}
|
||||
@@ -671,7 +728,7 @@ const MODEL_CUSTOM = "__custom__";
|
||||
/**
|
||||
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
|
||||
* own list (Codex's models cache, Claude's aliases named after the newest
|
||||
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
|
||||
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
|
||||
* Claude's static aliases only when the server cannot answer. `loaded` once
|
||||
* the list is final.
|
||||
*/
|
||||
@@ -707,7 +764,7 @@ export function ModelPicker({
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
tool: LaneTool;
|
||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
||||
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||
choices?: ModelChoice[];
|
||||
variant?: "field" | "ghost";
|
||||
}) {
|
||||
@@ -781,7 +838,7 @@ export function ModelMenu({
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
tool: LaneTool;
|
||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
||||
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||
choices?: ModelChoice[];
|
||||
/** Omitted for a tool that takes no effort. */
|
||||
effort?: EffortLevel;
|
||||
|
||||
@@ -9,11 +9,15 @@
|
||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
||||
* without a retry the pane silently freezes on its last painted frame — it
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Retries back off from 1.5 s to 30 s (reset once a socket opens) and stop on
|
||||
* close code 1008. xterm and its addons are loaded on the first terminal
|
||||
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image. The pane also registers with
|
||||
* Files (images or any other) dropped on it are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||
* a file. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -32,12 +36,10 @@
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
@@ -45,7 +47,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -72,11 +74,49 @@ const clipboardProvider: IClipboardProvider = {
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||
const ClipboardWithProvider = ClipboardAddon as unknown as new (
|
||||
base64: undefined,
|
||||
provider: IClipboardProvider
|
||||
) => ClipboardAddon;
|
||||
|
||||
/** xterm and its addons (~400 KB) load with the first terminal, not with the
|
||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||
* awaits `loadXterm()` first) sets up synchronously. */
|
||||
const importXterm = async () => {
|
||||
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-canvas"),
|
||||
import("@xterm/addon-clipboard"),
|
||||
import("@xterm/addon-search"),
|
||||
import("@xterm/addon-web-links"),
|
||||
]);
|
||||
return {
|
||||
Terminal: xterm.Terminal,
|
||||
FitAddon: fit.FitAddon,
|
||||
CanvasAddon: canvas.CanvasAddon,
|
||||
SearchAddon: search.SearchAddon,
|
||||
WebLinksAddon: links.WebLinksAddon,
|
||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||
ClipboardWithProvider: clipboard.ClipboardAddon as unknown as new (
|
||||
base64: undefined,
|
||||
provider: IClipboardProvider
|
||||
) => ClipboardAddon,
|
||||
};
|
||||
};
|
||||
type Xterm = Awaited<ReturnType<typeof importXterm>>;
|
||||
let xtermLoaded: Xterm | null = null;
|
||||
let xtermLoading: Promise<Xterm> | null = null;
|
||||
export function loadXterm(): Promise<Xterm> {
|
||||
xtermLoading ??= importXterm().then(
|
||||
(x) => (xtermLoaded = x),
|
||||
(err) => {
|
||||
xtermLoading = null; // a failed chunk fetch may be retried by the next pane
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
return xtermLoading;
|
||||
}
|
||||
|
||||
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||
const RETRY_MIN_MS = 1500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
@@ -144,13 +184,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
@@ -160,265 +200,293 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
// unset, xterm falls back to the browser/OS default (`courier-new`),
|
||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||
// substitutes those specific glyphs from a different face with a
|
||||
// mismatched advance width, corrupting the cell it's drawn into even
|
||||
// under the canvas renderer (which only stops a glyph from shoving its
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too.
|
||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||
// it on a PTY, where termios already does that translation for the streams
|
||||
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
|
||||
// drawing at the column it was on; with this on, that draw landed at
|
||||
// column 0 instead, stamping the first characters of the line into the
|
||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
const search = new SearchAddon();
|
||||
term.loadAddon(search);
|
||||
searchRef.current = search;
|
||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((_e, uri) => {
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
})
|
||||
);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
term.textarea?.setAttribute("aria-describedby", hintId);
|
||||
|
||||
// Draw each cell at a fixed position instead of letting the browser lay the
|
||||
// row out as text. xterm's default DOM renderer aligns a row with a single
|
||||
// `letter-spacing`, which only holds while every glyph comes from one
|
||||
// monospace font — Vietnamese diacritics and CJK fall through to a fallback
|
||||
// face whose advance width differs, so glyphs shove their neighbours along
|
||||
// and the row's tail spills onto the row below. The buffer is correct
|
||||
// throughout (copying the line yields clean text); only the painting drifts,
|
||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
||||
// context keeps the DOM renderer rather than losing the terminal.
|
||||
let canvas: CanvasAddon | null = null;
|
||||
try {
|
||||
canvas = new CanvasAddon();
|
||||
term.loadAddon(canvas);
|
||||
} catch {
|
||||
canvas = null;
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
if (autoFocusRef.current) term.focus();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
// press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
|
||||
// the wheel silently walks the prompt history instead of scrolling. Kill
|
||||
// ONLY that branch: xterm runs this same handler before sending a real SGR
|
||||
// mouse report too (`bindMouse`'s wheel case consults it), so a blanket
|
||||
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
||||
// because that protocol reports button presses only, never the wheel — so
|
||||
// xterm takes the emulation path for it as well.
|
||||
term.attachCustomWheelEventHandler(() => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
const tracked = mode !== "none" && mode !== "x10";
|
||||
return tracked || term.buffer.active.type !== "alternate";
|
||||
});
|
||||
|
||||
// xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
|
||||
// event cancelled, so the browser still runs its default action and moves
|
||||
// focus out of the terminal. The pane then only ever sees the first press,
|
||||
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
|
||||
// here while working in a real terminal. Returning true keeps xterm's own
|
||||
// handling; the preventDefault only stops focus from escaping.
|
||||
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
|
||||
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
|
||||
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
|
||||
// handling — and from cancelling — the key.
|
||||
term.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.type !== "keydown") return true;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
|
||||
ev.preventDefault();
|
||||
tabMovesRef.current = !tabMovesRef.current;
|
||||
setTabMoves(tabMovesRef.current);
|
||||
return false;
|
||||
}
|
||||
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
|
||||
// which is readline's forward-char (and bound by many TUIs).
|
||||
const findChord =
|
||||
!ev.altKey &&
|
||||
key === "f" &&
|
||||
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey));
|
||||
if (findChord) {
|
||||
ev.preventDefault();
|
||||
setFind((f) => f ?? "");
|
||||
requestAnimationFrame(() => findInput.current?.focus());
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
/**
|
||||
* Tell the server this terminal's size. tmux draws the pane at whatever
|
||||
* width it last heard, and a pane WIDER than xterm is silently corrupting:
|
||||
* every over-long row autowraps its tail onto the next row's left margin,
|
||||
* and tmux — believing that row unchanged — never repaints over it. The
|
||||
* result is stray characters glued to the left edge of blank lines, which
|
||||
* only becomes visible once a scroll repaints history. Reproduced with a
|
||||
* one-column difference; two columns leaves two characters per line.
|
||||
*
|
||||
* A zero-size fit is never reported: the console collapses with CSS
|
||||
* (`display:none` leaves the container 0×0), and fitting to that would
|
||||
* resize the real tmux pane down to nothing.
|
||||
*/
|
||||
const sendSizeNow = () => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (term.cols < 2 || term.rows < 2) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
// A divider drag or a window resize fires a resize per frame; tmux and the
|
||||
// program in the pane redraw on each, so the LAST size is sent once things
|
||||
// settle (trailing, 150 ms).
|
||||
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const sendSize = () => {
|
||||
if (sizeTimer) clearTimeout(sizeTimer);
|
||||
sizeTimer = setTimeout(() => {
|
||||
sizeTimer = null;
|
||||
sendSizeNow();
|
||||
}, 150);
|
||||
};
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let ended = false; // server said the pane exited — nothing left to attach to
|
||||
let disposed = false;
|
||||
const attach = ({
|
||||
Terminal,
|
||||
FitAddon,
|
||||
CanvasAddon,
|
||||
SearchAddon,
|
||||
WebLinksAddon,
|
||||
ClipboardWithProvider,
|
||||
}: Xterm) => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
// unset, xterm falls back to the browser/OS default (`courier-new`),
|
||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||
// substitutes those specific glyphs from a different face with a
|
||||
// mismatched advance width, corrupting the cell it's drawn into even
|
||||
// under the canvas renderer (which only stops a glyph from shoving its
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too.
|
||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||
// it on a PTY, where termios already does that translation for the streams
|
||||
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
|
||||
// drawing at the column it was on; with this on, that draw landed at
|
||||
// column 0 instead, stamping the first characters of the line into the
|
||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
const search = new SearchAddon();
|
||||
term.loadAddon(search);
|
||||
searchRef.current = search;
|
||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((_e, uri) => {
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
})
|
||||
);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
term.textarea?.setAttribute("aria-describedby", hintId);
|
||||
|
||||
const connect = () => {
|
||||
// The attach itself carries this terminal's size. tmux sizes the window
|
||||
// to the client that attached most recently, so spawning the attach at a
|
||||
// default 80×24 and correcting it from the first `resize` message a beat
|
||||
// later makes the pane's program (claude) redraw its whole UI at 80
|
||||
// columns first — and everything painted at that width stays in the
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// lane switch, and each 1.5 s reconnect.
|
||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||
sock.binaryType = "arraybuffer";
|
||||
// Draw each cell at a fixed position instead of letting the browser lay the
|
||||
// row out as text. xterm's default DOM renderer aligns a row with a single
|
||||
// `letter-spacing`, which only holds while every glyph comes from one
|
||||
// monospace font — Vietnamese diacritics and CJK fall through to a fallback
|
||||
// face whose advance width differs, so glyphs shove their neighbours along
|
||||
// and the row's tail spills onto the row below. The buffer is correct
|
||||
// throughout (copying the line yields clean text); only the painting drifts,
|
||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
||||
// context keeps the DOM renderer rather than losing the terminal.
|
||||
let canvas: CanvasAddon | null = null;
|
||||
try {
|
||||
canvas = new CanvasAddon();
|
||||
term.loadAddon(canvas);
|
||||
} catch {
|
||||
canvas = null;
|
||||
}
|
||||
|
||||
sock.onopen = () => {
|
||||
setReconnecting(false);
|
||||
sendSizeNow();
|
||||
};
|
||||
sock.onmessage = (event) => {
|
||||
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
|
||||
// to the next decode() call when a PTY chunk splits mid-character —
|
||||
// without it, a Vietnamese/accented char split across two WS frames
|
||||
// decodes to garbage on both sides (U+FFFD or stray bytes).
|
||||
const data = isArrayBuffer
|
||||
? decoder.decode(event.data as ArrayBuffer, { stream: true })
|
||||
: event.data;
|
||||
if (typeof data === "string") {
|
||||
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||
if (data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
onExitRef.current?.();
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* not JSON — fall through and render as PTY output */
|
||||
}
|
||||
}
|
||||
term.write(data);
|
||||
fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
if (autoFocusRef.current) term.focus();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
// press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
|
||||
// the wheel silently walks the prompt history instead of scrolling. Kill
|
||||
// ONLY that branch: xterm runs this same handler before sending a real SGR
|
||||
// mouse report too (`bindMouse`'s wheel case consults it), so a blanket
|
||||
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
||||
// because that protocol reports button presses only, never the wheel — so
|
||||
// xterm takes the emulation path for it as well.
|
||||
term.attachCustomWheelEventHandler(() => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
const tracked = mode !== "none" && mode !== "x10";
|
||||
return tracked || term.buffer.active.type !== "alternate";
|
||||
});
|
||||
|
||||
// xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
|
||||
// event cancelled, so the browser still runs its default action and moves
|
||||
// focus out of the terminal. The pane then only ever sees the first press,
|
||||
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
|
||||
// here while working in a real terminal. Returning true keeps xterm's own
|
||||
// handling; the preventDefault only stops focus from escaping.
|
||||
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
|
||||
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
|
||||
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
|
||||
// handling — and from cancelling — the key.
|
||||
term.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.type !== "keydown") return true;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
|
||||
ev.preventDefault();
|
||||
tabMovesRef.current = !tabMovesRef.current;
|
||||
setTabMoves(tabMovesRef.current);
|
||||
return false;
|
||||
}
|
||||
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
|
||||
// which is readline's forward-char (and bound by many TUIs).
|
||||
const findChord =
|
||||
!ev.altKey &&
|
||||
key === "f" &&
|
||||
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) ||
|
||||
(ev.ctrlKey && ev.shiftKey && !ev.metaKey));
|
||||
if (findChord) {
|
||||
ev.preventDefault();
|
||||
setFind((f) => f ?? "");
|
||||
requestAnimationFrame(() => findInput.current?.focus());
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
/**
|
||||
* Tell the server this terminal's size. tmux draws the pane at whatever
|
||||
* width it last heard, and a pane WIDER than xterm is silently corrupting:
|
||||
* every over-long row autowraps its tail onto the next row's left margin,
|
||||
* and tmux — believing that row unchanged — never repaints over it. The
|
||||
* result is stray characters glued to the left edge of blank lines, which
|
||||
* only becomes visible once a scroll repaints history. Reproduced with a
|
||||
* one-column difference; two columns leaves two characters per line.
|
||||
*
|
||||
* A zero-size fit is never reported: the console collapses with CSS
|
||||
* (`display:none` leaves the container 0×0), and fitting to that would
|
||||
* resize the real tmux pane down to nothing.
|
||||
*/
|
||||
const sendSizeNow = () => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (term.cols < 2 || term.rows < 2) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
sock.onclose = () => {
|
||||
if (disposed || ended) return;
|
||||
// tmux keeps the session alive across a server restart, so re-attaching
|
||||
// repaints the pane in full — no output is lost by retrying. Input is
|
||||
// not: the overlay says so rather than letting keystrokes vanish.
|
||||
setReconnecting(true);
|
||||
retryTimer = setTimeout(connect, 1500);
|
||||
// A divider drag or a window resize fires a resize per frame; tmux and the
|
||||
// program in the pane redraw on each, so the LAST size is sent once things
|
||||
// settle (trailing, 150 ms).
|
||||
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const sendSize = () => {
|
||||
if (sizeTimer) clearTimeout(sizeTimer);
|
||||
sizeTimer = setTimeout(() => {
|
||||
sizeTimer = null;
|
||||
sendSizeNow();
|
||||
}, 150);
|
||||
};
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let ended = false; // server said the pane exited — nothing left to attach to
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
|
||||
const connect = () => {
|
||||
// The attach itself carries this terminal's size. tmux sizes the window
|
||||
// to the client that attached most recently, so spawning the attach at a
|
||||
// default 80×24 and correcting it from the first `resize` message a beat
|
||||
// later makes the pane's program (claude) redraw its whole UI at 80
|
||||
// columns first — and everything painted at that width stays in the
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// lane switch, and each reconnect.
|
||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||
sock.binaryType = "arraybuffer";
|
||||
|
||||
sock.onopen = () => {
|
||||
retryMs = RETRY_MIN_MS;
|
||||
setReconnecting(false);
|
||||
sendSizeNow();
|
||||
};
|
||||
sock.onmessage = (event) => {
|
||||
const isArrayBuffer =
|
||||
Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
|
||||
// to the next decode() call when a PTY chunk splits mid-character —
|
||||
// without it, a Vietnamese/accented char split across two WS frames
|
||||
// decodes to garbage on both sides (U+FFFD or stray bytes).
|
||||
const data = isArrayBuffer
|
||||
? decoder.decode(event.data as ArrayBuffer, { stream: true })
|
||||
: event.data;
|
||||
if (typeof data === "string") {
|
||||
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||
if (data.startsWith('{"type"')) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
onExitRef.current?.();
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* not JSON — fall through and render as PTY output */
|
||||
}
|
||||
}
|
||||
term.write(data);
|
||||
}
|
||||
};
|
||||
sock.onclose = (e?: CloseEvent) => {
|
||||
// 1008: the server refused this attach (policy) — retrying cannot help.
|
||||
if (disposed || ended || e?.code === 1008) return;
|
||||
// tmux keeps the session alive across a server restart, so re-attaching
|
||||
// repaints the pane in full — no output is lost by retrying. Input is
|
||||
// not: the overlay says so rather than letting keystrokes vanish.
|
||||
// Backoff (1.5 s → 30 s, reset on open) so a server that stays down is
|
||||
// not hammered by every open pane.
|
||||
setReconnecting(true);
|
||||
retryTimer = setTimeout(connect, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
});
|
||||
|
||||
// xterm's own size change is the event worth reporting, not the
|
||||
// container's: cols also move when the webfont finishes loading or the
|
||||
// page zooms, neither of which resizes the element ResizeObserver watches.
|
||||
const resizeDisposable = term.onResize(sendSize);
|
||||
|
||||
// Re-assert on focus. Several attach clients can share one tmux session
|
||||
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
|
||||
// the window to whichever client was last active — resizing this pane out
|
||||
// from under a terminal that is never told. Coming back to it says so.
|
||||
const reassert = () => sendSizeNow();
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
const refit = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
const el = containerRef.current;
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
fit.fit(); // a real change fires onResize, which reports it
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(refit);
|
||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||
// A webfont landing after open changes the cell, not the container, so the
|
||||
// observer never fires — and xterm re-measures its cell only on a font
|
||||
// option change or a resize, so the fit kept the fallback font's cell and
|
||||
// the rows/cols overflowed the pane. Re-setting the family (a trailing
|
||||
// space keeps it a change) makes xterm measure again before the fit.
|
||||
const onFontLoaded = () => {
|
||||
const family = term.options.fontFamily ?? "";
|
||||
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
|
||||
return () => {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (sizeTimer) clearTimeout(sizeTimer);
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
};
|
||||
connect();
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
});
|
||||
|
||||
// xterm's own size change is the event worth reporting, not the
|
||||
// container's: cols also move when the webfont finishes loading or the
|
||||
// page zooms, neither of which resizes the element ResizeObserver watches.
|
||||
const resizeDisposable = term.onResize(sendSize);
|
||||
|
||||
// Re-assert on focus. Several attach clients can share one tmux session
|
||||
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
|
||||
// the window to whichever client was last active — resizing this pane out
|
||||
// from under a terminal that is never told. Coming back to it says so.
|
||||
const reassert = () => sendSizeNow();
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
const refit = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
const el = containerRef.current;
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
fit.fit(); // a real change fires onResize, which reports it
|
||||
// Synchronous once xterm is loaded; the first pane waits for the chunk, and
|
||||
// `disposed` drops a setup that lands after this effect was cleaned up.
|
||||
let teardown: (() => void) | undefined;
|
||||
const start = (x: Xterm) => {
|
||||
if (!disposed) teardown = attach(x);
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(refit);
|
||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||
// A webfont landing after open changes the cell, not the container, so the
|
||||
// observer never fires — and xterm re-measures its cell only on a font
|
||||
// option change or a resize, so the fit kept the fallback font's cell and
|
||||
// the rows/cols overflowed the pane. Re-setting the family (a trailing
|
||||
// space keeps it a change) makes xterm measure again before the fit.
|
||||
const onFontLoaded = () => {
|
||||
const family = term.options.fontFamily ?? "";
|
||||
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
|
||||
if (xtermLoaded) start(xtermLoaded);
|
||||
else loadXterm().then(start, () => undefined);
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (sizeTimer) clearTimeout(sizeTimer);
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
teardown?.();
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
@@ -583,9 +651,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
action: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file PaneGrid.test.ts
|
||||
* @description The pane drop preview: it draws where the dragged pane will
|
||||
* actually land (`dropPreview` over `applyDrop`), not half of the target's
|
||||
* current box, and nothing for a drop that changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dropPreview } from "../PaneGrid";
|
||||
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
|
||||
|
||||
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
|
||||
describe("dropPreview", () => {
|
||||
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
|
||||
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
|
||||
});
|
||||
|
||||
it("a pane from another tab splits the target's current box", () => {
|
||||
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||
});
|
||||
|
||||
it("the center swaps into the target's box", () => {
|
||||
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
|
||||
});
|
||||
|
||||
it("nothing on its own pane or on a refused move", () => {
|
||||
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
|
||||
const full: PaneLayout = {
|
||||
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
|
||||
focus: 0,
|
||||
};
|
||||
expect(dropPreview(full, 5, 0, "left")).toBeNull();
|
||||
});
|
||||
});
|
||||
+33
-28
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* @file RouterPicker.test.tsx
|
||||
* @description The per-session AI Router picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
||||
* @file ProxyPicker.test.tsx
|
||||
* @description The per-session AI Proxy picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a proxy,
|
||||
* remembering the pick, and falling back to Native when the remembered
|
||||
* profile is gone; plus `routerArg`, which never sends native.
|
||||
* profile is gone; plus `proxyArg`, which never sends native.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type { AiRouter } from "../../../lib/api";
|
||||
import type { AiProxy } from "../../../lib/api";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", routerCapable: true },
|
||||
{ id: "gemini", label: "Gemini", routerCapable: false },
|
||||
{ id: "claude", label: "Claude Code", proxyCapable: true },
|
||||
{ id: "gemini", label: "Gemini", proxyCapable: false },
|
||||
].map((x) => ({ ...x, installed: true, enabled: true })),
|
||||
}),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiProxies: { list: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(api.aiRouters.list)
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native, nine] } as never);
|
||||
});
|
||||
|
||||
describe("RouterPicker", () => {
|
||||
describe("ProxyPicker", () => {
|
||||
it("lists the saved profiles for a tool that can use one", async () => {
|
||||
render(
|
||||
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
|
||||
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
|
||||
render(
|
||||
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
const picker = screen.getByTestId("router-picker");
|
||||
const picker = screen.getByTestId("proxy-picker");
|
||||
expect(picker.querySelector("button")).toBeDisabled();
|
||||
expect(picker).not.toHaveTextContent("9Router");
|
||||
});
|
||||
|
||||
it("falls back to Native when the remembered profile is gone", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
|
||||
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||
});
|
||||
|
||||
it("useAiRouters reads the saved profiles once", async () => {
|
||||
const { result } = renderHook(() => useAiRouters());
|
||||
it("useAiProxies reads the saved profiles once", async () => {
|
||||
const { result } = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
||||
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionRouter / routerArg", () => {
|
||||
describe("useSessionProxy / proxyArg", () => {
|
||||
it("remembers the last pick for the next session", () => {
|
||||
const first = renderHook(() => useSessionRouter());
|
||||
const first = renderHook(() => useSessionProxy());
|
||||
expect(first.result.current[0]).toBe("native");
|
||||
act(() => first.result.current[1]("9router"));
|
||||
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
|
||||
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||
});
|
||||
|
||||
it("sends a router only for a capable tool and never native", () => {
|
||||
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
|
||||
expect(routerArg(true, "native")).toEqual({});
|
||||
expect(routerArg(false, "9router")).toEqual({});
|
||||
it("still reads the pick remembered before the rename", () => {
|
||||
localStorage.setItem("agenthub.session.router", "9router");
|
||||
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||
});
|
||||
|
||||
it("sends a proxy only for a capable tool and never native", () => {
|
||||
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
|
||||
expect(proxyArg(true, "native")).toEqual({});
|
||||
expect(proxyArg(false, "9router")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Router's models) when given one,
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
* same list as one chip: aliases up front, pinned versions under More models,
|
||||
* plus the effort slider.
|
||||
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
items: [
|
||||
@@ -59,6 +59,25 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
plugins: r({
|
||||
plugins: [
|
||||
{
|
||||
name: "vercel",
|
||||
enabled: true,
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [{ name: "deploy", description: "Deploy to Vercel" }],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off",
|
||||
enabled: false,
|
||||
contributes: { items: { skills: [{ name: "deployer" }], commands: [] } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -96,6 +115,9 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
// Claude Code's bundled skills, matched on a word of their name.
|
||||
expect.stringContaining("/code-review"),
|
||||
expect.stringContaining("/security-review"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the session will start in, not the server's own.
|
||||
@@ -108,6 +130,13 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
expect(spies.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers enabled plugins' skills under their plugin's name", async () => {
|
||||
renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/deplo" } });
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining("/vercel:deploy")]);
|
||||
});
|
||||
|
||||
it("suggests files under the folder on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the caller: put the cursor at the end of it.
|
||||
@@ -166,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
|
||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
||||
expect(api.run.dropImage).toHaveBeenCalledWith(img);
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(img);
|
||||
// The path is not typed into the prompt; it goes with it on send.
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||
|
||||
@@ -175,16 +204,43 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove attachment" }));
|
||||
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||
});
|
||||
|
||||
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" });
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||
expect(api.run.dropImage).not.toHaveBeenCalled();
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,7 +267,7 @@ describe("ModelPicker", () => {
|
||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers only a fixed list when given one (an AI Router's models)", async () => {
|
||||
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
|
||||
render(
|
||||
<ModelPicker
|
||||
value=""
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`,
|
||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||
* after `exit` or a policy close,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { pasteToRun } from "../../../lib/panePaste";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -20,9 +21,9 @@ const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -87,7 +88,7 @@ class MockWebSocket {
|
||||
sent: unknown[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: unknown }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onclose: ((e?: { code: number }) => void) | null = null;
|
||||
readyState = 1; // OPEN — the component only sends on an open socket
|
||||
static OPEN = 1;
|
||||
constructor(url: string) {
|
||||
@@ -105,6 +106,9 @@ class MockWebSocket {
|
||||
global.WebSocket = MockWebSocket;
|
||||
|
||||
describe("TerminalView", () => {
|
||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||
beforeAll(() => loadXterm());
|
||||
|
||||
beforeEach(() => {
|
||||
termState.modes.mouseTrackingMode = "none";
|
||||
termState.buffer.active.type = "alternate";
|
||||
@@ -388,6 +392,23 @@ describe("TerminalView", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("backs off between failed re-attaches, and stops on a policy close (1008)", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
MockWebSocket.instances[0]!.close();
|
||||
vi.advanceTimersByTime(1600);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
MockWebSocket.instances[1]!.close(); // failed again: now 3 s
|
||||
vi.advanceTimersByTime(1600);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(MockWebSocket.instances).toHaveLength(3);
|
||||
act(() => MockWebSocket.instances[2]!.onclose?.({ code: 1008 }));
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(MockWebSocket.instances).toHaveLength(3);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not re-attach once the server reported the pane exited", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
@@ -400,29 +421,36 @@ describe("TerminalView", () => {
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
it("pastes every path of several dropped files, images or not", async () => {
|
||||
dropFileMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
const img = new File(["x"], "b.png", { type: "image/png" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
dataTransfer: { types: ["Files"], files: [txt, img] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith(
|
||||
"/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png "
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
"intellij-light": "IntelliJ Light",
|
||||
canvas: "Canvas",
|
||||
"orca-dark": "Orca Dark",
|
||||
"orca-light": "Orca Light",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
||||
// badge current the way the old Settings page did, every 10 s.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (!(document.visibilityState === "visible")) return;
|
||||
api.settings
|
||||
.info()
|
||||
.then((r) => setHooks(r.hooks))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
||||
* and Codex, the Antigravity tab itself): Claude
|
||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a
|
||||
* **Show all models** switch lists every row, and a row added outside the
|
||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @file UsersSettings.tsx
|
||||
* @description Settings → Users, server mode only and only for an admin or
|
||||
* analyst (docs/SERVER-MODE.md): every account with its role, state,
|
||||
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
|
||||
* (`GET /api/users`), and the audit log of staff reads of members' sessions
|
||||
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
||||
* `agenthub users …` on the host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function UsersSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch((e: Error) => setError(e.message));
|
||||
api.users
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
||||
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{u.role}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
||||
{audit?.length === 0 && (
|
||||
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
|
||||
)}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
proxyCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
modelFlag: false,
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("AppearanceSettings", () => {
|
||||
"Apple Dark",
|
||||
"Orca Dark",
|
||||
"Apple Light",
|
||||
"IntelliJ Light",
|
||||
"Canvas",
|
||||
"Orca Light",
|
||||
]) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { laneChangeMatcher } from "../../lib/laneChange";
|
||||
import type { PipelineStats } from "../../lib/types";
|
||||
|
||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||
@@ -52,8 +53,12 @@ export function LanePipelinePanel({
|
||||
}, [refresh]);
|
||||
|
||||
// Stages move as agents declare them; throttled (not debounced) so a burst
|
||||
// costs one fetch and a steady stream still refreshes every 3 s.
|
||||
useLiveRefresh(() => void refresh(), { match: (msg) => msg.type === "lane_update" });
|
||||
// costs one fetch and a steady stream still refreshes every 3 s. A lane's
|
||||
// re-broadcast that moved no stage column is ignored.
|
||||
const [stageChanged] = useState(() =>
|
||||
laneChangeMatcher((l) => `${l.pipeline}:${l.stage}:${l.active_feature_id}`)
|
||||
);
|
||||
useLiveRefresh(() => void refresh(), { match: stageChanged });
|
||||
|
||||
const hasPipelines = !!data && data.pipelines.length > 0;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,8 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Throttled (not debounced): a steady event stream still refreshes every 3 s.
|
||||
// Throttled (not debounced): a steady event stream still refreshes, every
|
||||
// 15 s — it is an aggregate over every event.
|
||||
useLiveRefresh(() => void refresh(), {
|
||||
throttleMs: 15000,
|
||||
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file useSpin.test.ts
|
||||
* @description Tests the Refresh-button hook: the reload runs with no
|
||||
* arguments, a click while it is in flight is dropped, and the spin lasts at
|
||||
* least the floor even when the reload answers at once — also under StrictMode's
|
||||
* mount → unmount → mount, which once left it spinning for good.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useSpin } from "../useSpin";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useSpin", () => {
|
||||
it("drops a click while the reload is in flight, and spins for the floor after it answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
let answer!: () => void;
|
||||
const load = vi.fn(() => new Promise<void>((r) => (answer = r)));
|
||||
const { result } = renderHook(() => useSpin(load, 600));
|
||||
|
||||
act(() => {
|
||||
void result.current[1]();
|
||||
});
|
||||
expect(result.current[0]).toBe(true);
|
||||
expect(load).toHaveBeenCalledWith();
|
||||
act(() => {
|
||||
void result.current[1]();
|
||||
});
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Answered at once: still spinning until the floor, but a new click reloads.
|
||||
await act(async () => answer());
|
||||
expect(result.current[0]).toBe(true);
|
||||
act(() => {
|
||||
void result.current[1]();
|
||||
});
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
await act(async () => {
|
||||
answer();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
});
|
||||
expect(result.current[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("stops spinning under StrictMode's double mount", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useSpin(() => undefined, 600), { wrapper: StrictMode });
|
||||
await act(async () => {
|
||||
void result.current[1]();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
});
|
||||
expect(result.current[0]).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file useThrottledValue.test.ts
|
||||
* @description Tests for `useThrottledValue`: the first change lands at once, a
|
||||
* burst inside the window collapses into one trailing update with the last value.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useThrottledValue } from "../useThrottledValue";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useThrottledValue", () => {
|
||||
it("is leading + trailing", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result, rerender } = renderHook(({ v }) => useThrottledValue(v, 3000), {
|
||||
initialProps: { v: "a" },
|
||||
});
|
||||
expect(result.current).toBe("a");
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
rerender({ v: "b" });
|
||||
expect(result.current).toBe("b"); // leading
|
||||
rerender({ v: "c" });
|
||||
rerender({ v: "d" });
|
||||
expect(result.current).toBe("b"); // inside the window
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(result.current).toBe("d"); // trailing, last value
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,9 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
||||
if (!enabled) return;
|
||||
const live = throttle(() => loadRef.current(), throttleMs);
|
||||
const visible = () => document.visibilityState === "visible";
|
||||
// A hidden tab ignores events: onVis reloads once when it is shown again.
|
||||
const offMsg = eventBus.subscribe((msg) => {
|
||||
if (matchRef.current?.(msg)) live();
|
||||
if (visible() && matchRef.current?.(msg)) live();
|
||||
});
|
||||
let wasDown = !eventBus.connected;
|
||||
// (Guarded: test doubles of the bus often stub `subscribe` only.)
|
||||
@@ -47,7 +48,7 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
||||
if (!up) wasDown = true;
|
||||
else if (wasDown) {
|
||||
wasDown = false;
|
||||
live();
|
||||
if (visible()) live();
|
||||
}
|
||||
})
|
||||
: () => undefined;
|
||||
|
||||
@@ -121,9 +121,11 @@ export function useNotifications() {
|
||||
type: NotifType,
|
||||
title: string,
|
||||
body: string,
|
||||
url?: Promise<string | undefined>
|
||||
// A thunk: the pane lookup (GET /api/run) runs only for a
|
||||
// notification that is actually shown, never per incoming event.
|
||||
url?: () => Promise<string | undefined>
|
||||
) =>
|
||||
void Promise.resolve(url)
|
||||
void Promise.resolve(url?.())
|
||||
.then((u) => notify(title, body, u))
|
||||
.then((r) => {
|
||||
if (r.via === "native" || r.via === "push" || r.via === "local")
|
||||
@@ -138,7 +140,7 @@ export function useNotifications() {
|
||||
"onNewSession",
|
||||
i18n.t("errors:notifications.newSession"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
() => laneUrlForSession(s.id)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export function useNotifications() {
|
||||
"onSessionError",
|
||||
i18n.t("errors:notifications.sessionError"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
() => laneUrlForSession(s.id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -158,10 +160,7 @@ export function useNotifications() {
|
||||
if (!prefs.onSubagentSpawn) return;
|
||||
const a = msg.data as Agent;
|
||||
if (a.type === "subagent") {
|
||||
show(
|
||||
"onSubagentSpawn",
|
||||
i18n.t("errors:notifications.subagentSpawned"),
|
||||
a.name,
|
||||
show("onSubagentSpawn", i18n.t("errors:notifications.subagentSpawned"), a.name, () =>
|
||||
laneUrlForSession(a.session_id)
|
||||
);
|
||||
}
|
||||
@@ -169,7 +168,7 @@ export function useNotifications() {
|
||||
}
|
||||
case "new_event": {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
const url = laneUrlForSession(ev.session_id);
|
||||
const url = () => laneUrlForSession(ev.session_id);
|
||||
// The two states where Claude is BLOCKED on the user get their own
|
||||
// flags (issue #29): `Notification` is a permission prompt or a
|
||||
// question, `Stop` is the end of a turn. Both used to hang off
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file useSpin.ts
|
||||
* @description A Refresh button's busy state. `run` calls the reload with no
|
||||
* arguments (an `onClick={load}` handed the click event to it) and keeps
|
||||
* `spinning` true for at least `minMs`, so a reload answered from a cache or
|
||||
* a throttle in a few milliseconds still shows the user that it ran.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* @returns `[spinning, run]`. A click while the reload is still in flight is
|
||||
* dropped; once it has answered, a click reloads again even if the icon is
|
||||
* still finishing its minimum spin.
|
||||
*/
|
||||
export function useSpin(fn: () => unknown, minMs = 600): [boolean, () => Promise<void>] {
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const inFlight = useRef(false);
|
||||
const lastRun = useRef(0);
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
const alive = useRef(true);
|
||||
// Set on mount too: StrictMode mounts, unmounts and mounts again, and a
|
||||
// cleanup-only effect left `alive` false — the spin never stopped and the
|
||||
// button stayed busy for good.
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const id = ++lastRun.current;
|
||||
const started = Date.now();
|
||||
setSpinning(true);
|
||||
try {
|
||||
await fnRef.current();
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
const rest = minMs - (Date.now() - started);
|
||||
if (rest > 0) await new Promise((r) => setTimeout(r, rest));
|
||||
// Only the newest run stops the spin: an older one's floor ending must
|
||||
// not cut short a reload clicked after it.
|
||||
if (alive.current && id === lastRun.current) setSpinning(false);
|
||||
}
|
||||
}, [minMs]);
|
||||
|
||||
return [spinning, run];
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export type ThemeMode = Theme | "system";
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
|
||||
light: ["default", "apple-light", "canvas", "orca-light"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
const MODE_KEY = "theme";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file useThrottledValue.ts
|
||||
* @description Returns `value`, but lets it change at most once per `ms`
|
||||
* (leading + trailing, via `lib/throttle.ts`): the first change shows at once,
|
||||
* a burst collapses into one more update when it settles, and the last value
|
||||
* always lands. For effect keys that move on every hook (a lane's status or
|
||||
* session flipping between two sessions) so the fetch they drive cannot storm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { throttle } from "../lib/throttle";
|
||||
|
||||
export function useThrottledValue<T>(value: T, ms: number): T {
|
||||
const [shown, setShown] = useState(value);
|
||||
const latest = useRef(value);
|
||||
latest.current = value;
|
||||
const push = useMemo(() => throttle(() => setShown(latest.current), ms), [ms]);
|
||||
useEffect(() => () => push.cancel(), [push]);
|
||||
useEffect(() => push(), [value, push]);
|
||||
return shown;
|
||||
}
|
||||
@@ -128,7 +128,10 @@ export function useWebSocket(onMessage: MessageHandler) {
|
||||
// restart is picked up within a few seconds rather than after a long
|
||||
// idle wait. The focus/online/visibility listeners below reconnect
|
||||
// instantly on top of this for the common "user comes back" case.
|
||||
const delay = Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000);
|
||||
// Jittered (×0.5–1.5) so a server restart is not met by every
|
||||
// browser reconnecting in the same instant.
|
||||
const delay =
|
||||
Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000) * (0.5 + Math.random());
|
||||
reconnectAttempts.current++;
|
||||
reconnectTimer.current = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
+32
-88
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description i18next bootstrap for the dashboard client. Registers bundled JSON
|
||||
* locale files for English (`en`) and Vietnamese (`vi`), wires browser language detection, and exports the configured
|
||||
* singleton consumed by `react-i18next`.
|
||||
* @description i18next bootstrap for the dashboard client. Loads the JSON locale
|
||||
* files for English (`en`) and Vietnamese (`vi`) lazily — only the active language
|
||||
* (and the `en` fallback) — wires browser language detection, and exports the
|
||||
* configured singleton consumed by `react-i18next` plus `i18nReady`, the promise
|
||||
* the entry point awaits before the first render.
|
||||
*
|
||||
* ## Namespaces
|
||||
* Translations are split by feature area (`dashboard`, `sessions`, `settings`,
|
||||
@@ -38,14 +40,7 @@
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./locales/en/common.json`
|
||||
* - `./locales/vi/common.json`
|
||||
* - `./locales/en/nav.json`
|
||||
* - `./locales/vi/nav.json`
|
||||
* - `./locales/en/dashboard.json`
|
||||
* - `./locales/vi/dashboard.json`
|
||||
* - `./locales/en/sessions.json`
|
||||
* - `./locales/vi/sessions.json`
|
||||
* - `./locales/<lng>/<ns>.json` (lazy, via `import.meta.glob`)
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
@@ -57,44 +52,30 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import i18n from "i18next";
|
||||
import i18n, { type BackendModule, type ResourceKey } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import common_en from "./locales/en/common.json";
|
||||
import common_vi from "./locales/vi/common.json";
|
||||
import nav_en from "./locales/en/nav.json";
|
||||
import nav_vi from "./locales/vi/nav.json";
|
||||
import dashboard_en from "./locales/en/dashboard.json";
|
||||
import dashboard_vi from "./locales/vi/dashboard.json";
|
||||
import sessions_en from "./locales/en/sessions.json";
|
||||
import sessions_vi from "./locales/vi/sessions.json";
|
||||
import activity_en from "./locales/en/activity.json";
|
||||
import activity_vi from "./locales/vi/activity.json";
|
||||
import analytics_en from "./locales/en/analytics.json";
|
||||
import analytics_vi from "./locales/vi/analytics.json";
|
||||
import workflows_en from "./locales/en/workflows.json";
|
||||
import workflows_vi from "./locales/vi/workflows.json";
|
||||
import settings_en from "./locales/en/settings.json";
|
||||
import settings_vi from "./locales/vi/settings.json";
|
||||
import kanban_en from "./locales/en/kanban.json";
|
||||
import kanban_vi from "./locales/vi/kanban.json";
|
||||
import errors_en from "./locales/en/errors.json";
|
||||
import errors_vi from "./locales/vi/errors.json";
|
||||
import updates_en from "./locales/en/updates.json";
|
||||
import updates_vi from "./locales/vi/updates.json";
|
||||
import ccConfig_en from "./locales/en/ccConfig.json";
|
||||
import ccConfig_vi from "./locales/vi/ccConfig.json";
|
||||
import codexConfig_en from "./locales/en/codexConfig.json";
|
||||
import codexConfig_vi from "./locales/vi/codexConfig.json";
|
||||
import run_en from "./locales/en/run.json";
|
||||
import run_vi from "./locales/vi/run.json";
|
||||
import alerts_en from "./locales/en/alerts.json";
|
||||
import alerts_vi from "./locales/vi/alerts.json";
|
||||
import splash_en from "./locales/en/splash.json";
|
||||
import splash_vi from "./locales/vi/splash.json";
|
||||
import lanes_en from "./locales/en/lanes.json";
|
||||
import lanes_vi from "./locales/vi/lanes.json";
|
||||
// Every locale file is its own lazy chunk (vite.config.ts groups them into one
|
||||
// chunk per language), so a page loads only the language it shows. i18next
|
||||
// reads them through this inline backend: `init` fetches the detected language
|
||||
// (plus the `en` fallback), and `changeLanguage` fetches a new one BEFORE it
|
||||
// switches, so no raw key is ever painted.
|
||||
const localeFiles = import.meta.glob<Record<string, unknown>>("./locales/*/*.json", {
|
||||
import: "default",
|
||||
});
|
||||
const lazyLocales: BackendModule = {
|
||||
type: "backend",
|
||||
init() {},
|
||||
read(lng, ns, cb) {
|
||||
const load = localeFiles[`./locales/${lng}/${ns}.json`];
|
||||
if (!load) return cb(null, {});
|
||||
load().then(
|
||||
(data) => cb(null, data as ResourceKey),
|
||||
(err) => cb(err, false)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Keep `<html lang>` on the active language, so a screen reader speaks the
|
||||
* page with the right voice and the browser hyphenates/translates correctly.
|
||||
@@ -105,50 +86,13 @@ export function syncDocumentLang(): void {
|
||||
}
|
||||
i18n.on("languageChanged", syncDocumentLang);
|
||||
|
||||
i18n
|
||||
/** Resolves once the detected language's namespaces are loaded; `main.tsx`
|
||||
* awaits it before the first render. */
|
||||
export const i18nReady = i18n
|
||||
.use(lazyLocales)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: {
|
||||
common: common_en,
|
||||
nav: nav_en,
|
||||
dashboard: dashboard_en,
|
||||
sessions: sessions_en,
|
||||
activity: activity_en,
|
||||
analytics: analytics_en,
|
||||
workflows: workflows_en,
|
||||
settings: settings_en,
|
||||
kanban: kanban_en,
|
||||
errors: errors_en,
|
||||
updates: updates_en,
|
||||
ccConfig: ccConfig_en,
|
||||
codexConfig: codexConfig_en,
|
||||
run: run_en,
|
||||
alerts: alerts_en,
|
||||
splash: splash_en,
|
||||
lanes: lanes_en,
|
||||
},
|
||||
vi: {
|
||||
common: common_vi,
|
||||
nav: nav_vi,
|
||||
dashboard: dashboard_vi,
|
||||
sessions: sessions_vi,
|
||||
activity: activity_vi,
|
||||
analytics: analytics_vi,
|
||||
workflows: workflows_vi,
|
||||
settings: settings_vi,
|
||||
kanban: kanban_vi,
|
||||
errors: errors_vi,
|
||||
updates: updates_vi,
|
||||
ccConfig: ccConfig_vi,
|
||||
codexConfig: codexConfig_vi,
|
||||
run: run_vi,
|
||||
alerts: alerts_vi,
|
||||
splash: splash_vi,
|
||||
lanes: lanes_vi,
|
||||
},
|
||||
},
|
||||
supportedLngs: ["en", "vi"],
|
||||
nonExplicitSupportedLngs: true,
|
||||
fallbackLng: "en",
|
||||
@@ -182,5 +126,5 @@ i18n
|
||||
|
||||
syncDocumentLang();
|
||||
|
||||
/** Configured i18next instance — import side effects run {@link init}. */
|
||||
/** Configured i18next instance — import side effects start {@link init}. */
|
||||
export default i18n;
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -51,6 +51,56 @@
|
||||
"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": {
|
||||
"entities": {
|
||||
"agents": "Agents"
|
||||
},
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search text…",
|
||||
"status": "Status",
|
||||
"allStatuses": "All statuses",
|
||||
"cwd": "Folder",
|
||||
"cwdPlaceholder": "/path/to/project",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"sortBy": "Sort by {{column}}",
|
||||
"exportCsv": "Export CSV",
|
||||
"exportJson": "Export JSON",
|
||||
"exportNote": "An export holds the whole filter, up to {{cap}} rows.",
|
||||
"exportTruncated": "{{total}} rows match; the export holds the first {{cap}}.",
|
||||
"rows": "{{from}}–{{to}} of {{total}}",
|
||||
"prev": "Previous page",
|
||||
"next": "Next page",
|
||||
"empty": "Nothing matches these filters.",
|
||||
"reset": "Reset filters"
|
||||
},
|
||||
"tokensExCacheReads": "Tokens (excl. cache reads)",
|
||||
"cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
|
||||
"avgPerWeekday30d": "Average per weekday, last 30 days"
|
||||
|
||||
@@ -263,7 +263,8 @@
|
||||
"title": "Claude Code profiles",
|
||||
"openButton": "Switch profile",
|
||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||
"warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.",
|
||||
"warning": "Running Claude Code sessions follow a switch on their own: they pick up the new login within about 30 seconds.",
|
||||
"mismatch": "The login in use belongs to {{owner}}, but Claude Code's config names {{config}}. Requests are billed to {{owner}}. Switch to the account you want to fix this.",
|
||||
"activeLabel": "Logged in now",
|
||||
"noActive": "No Claude account is logged in on this machine.",
|
||||
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
|
||||
|
||||
@@ -202,5 +202,32 @@
|
||||
"message": "Your edits have not been saved and will be lost.",
|
||||
"discard": "Discard",
|
||||
"keep": "Keep editing"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Sign in to AgentHub",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in…",
|
||||
"failed": "Wrong username or password",
|
||||
"tooMany": "Too many failed attempts — try again in a few minutes",
|
||||
"logout": "Sign out",
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
"sessions": "A session is one conversation with an AI agent, from start to finish.",
|
||||
"agents": "Agents are the AI workers: the main one in each session plus any helpers it started.",
|
||||
"tokens": "Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost.",
|
||||
"cost": "Estimated money spent, calculated from token counts and each model's price.",
|
||||
"events": "Every step the AI takes (running a tool, finishing a reply…) is recorded as one event.",
|
||||
"tokenMix": "Input = what you and the files sent the AI. Output = what the AI wrote. Cache read = text reused from memory (about 10× cheaper). Cache write = text saved to memory for reuse.",
|
||||
"spendPace": "How fast money is being spent, and where this month will end up if that speed continues.",
|
||||
"toolCalls": "How many actions the AI took: reading a file, editing, running a command…",
|
||||
"subagents": "Helper agents the main AI started to work on part of the task in parallel.",
|
||||
"compactions": "When a conversation gets too long, it is summarized to make room. Early details can be lost.",
|
||||
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
||||
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,8 +215,8 @@
|
||||
"proof.ticketReport": "Task report",
|
||||
"renameLabel": "Rename this workspace",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"router.native": "Native ({{tool}})",
|
||||
"router.label": "AI Router",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"runtime.boot": "▶ up",
|
||||
"runtime.busy.down": "stopping…",
|
||||
"runtime.busy.up": "booting…",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"activityFeed": "Activity Feed",
|
||||
"agentBoard": "Kanban Board",
|
||||
"agents": "Agents",
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
"brand": "AgentHub",
|
||||
@@ -67,6 +68,8 @@
|
||||
"today": "Today {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}",
|
||||
"budget": "Budget {{pct}}%",
|
||||
"budgetTitle": "{{name}}: {{spend}} of {{limit}} this period",
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "{{pct}}% used",
|
||||
"quotaTitle": "Usage",
|
||||
@@ -76,14 +79,19 @@
|
||||
"quotaRefreshing": "Refreshing…",
|
||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"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" }
|
||||
"quotaWindow": {
|
||||
"wk": "Week",
|
||||
"mo": "Month"
|
||||
}
|
||||
},
|
||||
"version": "Version",
|
||||
"openMenu": "Open menu",
|
||||
"resizeSidebar": "Resize sidebar (double-click to reset)",
|
||||
"closeMenu": "Close menu",
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now"
|
||||
|
||||
@@ -142,11 +142,10 @@
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop image to attach",
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here",
|
||||
"remove": "Remove image",
|
||||
"hint": "Drop files or images to attach",
|
||||
"uploading": "Uploading…",
|
||||
"failed": "Could not attach the file",
|
||||
"remove": "Remove attachment",
|
||||
"view": "View image"
|
||||
},
|
||||
"terminal": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,16 +569,21 @@
|
||||
"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).",
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"aiProxy": {
|
||||
"title": "AI Proxy",
|
||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||
"add": "Add profile",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
|
||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
@@ -579,16 +592,16 @@
|
||||
"keySet": "Saved",
|
||||
"keyMissing": "None",
|
||||
"fieldLabel": "Name",
|
||||
"fieldLabelHint": "What you will see in the workspace form. Free text.",
|
||||
"fieldLabelHint": "What you will see in the session's AI Proxy picker. Free text.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
|
||||
"fieldBaseUrlHint": "The proxy's root URL, without /v1 — e.g. http://localhost:20128. Claude Code adds /v1/messages and Codex /v1/responses; a /v1 you type is dropped.",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many proxies 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.",
|
||||
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this proxy's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the proxy really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
@@ -600,12 +613,12 @@
|
||||
},
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Its session files go with it, and a session that ran on it resumes on the vendor's own API.",
|
||||
"loading": "Loading profiles…",
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "No answer ({{status}}): {{message}}"
|
||||
"testOk": "answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "no answer ({{status}}): {{message}}"
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -687,7 +700,7 @@
|
||||
},
|
||||
"codexHome": {
|
||||
"title": "CODEX_HOME",
|
||||
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.",
|
||||
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Proxy layers into it, and starts every codex workspace with it.",
|
||||
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
|
||||
"placeholder": "/path/to/codex-home"
|
||||
},
|
||||
@@ -779,8 +792,8 @@
|
||||
"tool": {
|
||||
"description": "{{tool}}: path, usage, default model and config files."
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"aiProxy": {
|
||||
"title": "AI Proxy",
|
||||
"description": "Endpoints that stand in front of Claude Code and Codex."
|
||||
},
|
||||
"pricing": {
|
||||
@@ -802,6 +815,18 @@
|
||||
"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."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Accounts, their usage and the audit log."
|
||||
}
|
||||
},
|
||||
"backPrevious": "Back",
|
||||
@@ -839,6 +864,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.",
|
||||
@@ -848,5 +946,21 @@
|
||||
"importBody": "The file's preferences replace this browser's, then the page reloads.",
|
||||
"imported": "Imported {{count}} preference(s). Reloading…",
|
||||
"badFile": "That file is not an AgentHub preferences file."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
|
||||
"name": "User",
|
||||
"role": "Role",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"disabled": "disabled",
|
||||
"tokens": "{{n}} tokens",
|
||||
"manageHint": "Add, disable or change a user on the host: agenthub users …",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
|
||||
"auditEmpty": "No reads recorded yet."
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user