Compare commits

...

3 Commits

Author SHA1 Message Date
nntrivi2001 9a54978eef feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
2026-10-07 14:11:25 +07:00
nntrivi2001 ca821008e2 perf: v2.5.10 — ready for 10+ concurrent users, leaner locally
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated
browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max
2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server
RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli).

Server
- /api/analytics: the events-table scans run in the read worker
  (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes
  maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args
- /api/workflows/skill-usage: +event_type and a tool_name range instead of
  LIKE, so it uses idx_events_tool (784 ms → a few ms)
- idx_events_session_type_tool (session_id, event_type, tool_name,
  created_at) replaces idx_events_session_type: covering reads for session
  stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB
  cache_size and 256 MB mmap_size on both connections
- WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every
  DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable
  connection; a failed checkpoint restores the automatic one (hook COMMIT p95
  109 ms → 15 ms at 100 hooks/s)
- tmux: one list-sessions answers a whole GET /api/lanes and the heal beat
  (liveSessions), shared for 1 s and cleared by this process's new/kill —
  GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat
  skips tmux when nothing runs; attach options chained into 2 spawns
- hooks: TurnDuration/APIError deduped against one read per hook instead of a
  query per transcript entry (23 ms/hook on a 763-turn session); one lane
  scan per hook; hourly upserts skip rows that did not grow; frames are sent
  after the transaction commits (and after the pane binding); new_event
  carries its row id; a split pane's (slot 1–9) hooks no longer rebind the
  lane's session_id
- watchdog liveness probe runs ps asynchronously; transcript newline scan via
  indexOf; startup subagent backfill fingerprints the files it already checked
- pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB)
- static: hashed assets served from their pre-compressed .br/.gz sibling

Client
- hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes,
  lane cards, git header, changes panel, settings); no per-tab hourly
  /updates/check (the server already checks)
- refetch storms throttled (workspace tree/session list 3 s, /api/run only on
  a run-key change, notification pane lookup only when shown); WS reconnect
  jitter; terminal retry backoff 1.5 → 30 s, stops on 1008
- heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame;
  GET in-flight dedupe in request()
- lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi
  (only while enabled); only the active locale loads; Workspace chunk
  prefetched on a /workspace deep link
- Sagi: no work while disabled, pupil via CSS variables, memo avatar
- build: .br/.gz siblings, sourcemap hidden; service worker no longer caches
  /assets/ (cache v3 drops the old one)

Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook
exports, so a suite run from a linked worktree's pre-commit can no longer
commit into the real repository or set core.bare.

Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README,
docs/plans/perf/README.md (audit, checklist, results).
2026-10-07 13:24:15 +07:00
nntrivi2001 8fdda66893 fix: v2.5.9 — Vietnamese in the terminal on macOS, every slash command, the right Claude account
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_`
for every non-ASCII character, and a macOS LaunchAgent starts the dashboard
with no locale at all — Vietnamese text and Claude Code's logo came out as
underscores in every browser terminal.

- shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS,
  C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and
  Windows are left alone
- RunSetup.tsx: the `/` list now offers Claude Code's bundled skills
  (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and
  every enabled plugin's commands and skills as /<plugin>:<name>
- Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached
  per token), not by .claude.json: a running session can leave the
  credentials store holding one account's token under another account's
  name. The Profiles modal, its Check quota and the status-bar quota now show
  the account requests are really billed to and warn on the mismatch; a
  switch saves the outgoing token into its OWNER's profile (credentials only)
  instead of overwriting the profile .claude.json names; saving a profile
  while they disagree is refused (EMISMATCH); a saved profile holding another
  account's token reports EWRONGACCOUNT instead of that account's usage
- Running Claude Code sessions follow a switch on their own (they re-read the
  credentials store when it changes), so the "close your sessions first"
  warning is gone
2026-10-07 11:18:10 +07:00
173 changed files with 8711 additions and 1181 deletions
+9 -4
View File
@@ -333,7 +333,7 @@ graph TD
| `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 |
@@ -487,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.
@@ -911,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 |
@@ -966,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
+4 -2
View File
File diff suppressed because one or more lines are too long
+9 -3
View File
@@ -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,
@@ -92,6 +94,10 @@ Internal build — all rights reserved.
(`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
+284 -1
View File
@@ -160,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),
});
@@ -2303,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(
@@ -2719,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)"],
],
],
@@ -3668,6 +3700,9 @@ 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"],
@@ -3708,6 +3743,248 @@ function cmdStatusline(sub, flags) {
}
}
// ── 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
@@ -3725,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":
+10 -4
View File
@@ -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.
@@ -525,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
@@ -793,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
View File
@@ -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
+22 -7
View File
@@ -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,8 +64,6 @@ 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";
@@ -75,7 +73,10 @@ 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 }))
@@ -156,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={
@@ -92,6 +92,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import { useAiTools } from "../hooks/useAiTools";
import { useSpin } from "../hooks/useSpin";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -342,6 +343,7 @@ export function AlertsNotifications() {
setLoadingAlerts(false);
}
}, [unackedOnly, t]);
const [spinning, refreshAlerts] = useSpin(loadAlerts);
const loadMore = useCallback(async () => {
try {
@@ -1017,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
+163
View File
@@ -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>
);
}
+3 -3
View File
@@ -51,7 +51,7 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
* - `./Sagi/Sagi`
* - `./Sagi/SagiMount`
*
* ## Public surface
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
@@ -81,7 +81,7 @@ 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";
@@ -171,7 +171,7 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
</a>
<UpdateNotifier />
<Sagi />
<SagiMount />
{drawer && drawerOpen && (
<div
aria-hidden
+8 -1
View File
@@ -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;
+14 -9
View File
@@ -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,9 +199,13 @@ 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);
@@ -209,11 +217,11 @@ export function Sagi() {
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());
}),
@@ -223,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;
@@ -235,7 +242,7 @@ export function Sagi() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [enabled]);
}, []);
const onNavigate = useCallback(
(route: string) => {
@@ -279,8 +286,6 @@ export function Sagi() {
[brain, navigate, t]
);
if (!enabled) return null;
const anchor: Anchor = {
left: place.left,
top: place.top,
+19 -8
View File
@@ -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>
);
}
});
+29
View File
@@ -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>
);
}
+4 -1
View File
@@ -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);
}, []);
+20 -4
View File
@@ -81,19 +81,22 @@ interface SessionOverviewProps {
}
/** 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";
}) {
@@ -120,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}
@@ -310,6 +318,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
<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
@@ -322,6 +331,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<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" />}
@@ -329,6 +339,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<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
@@ -338,12 +349,14 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<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"}
@@ -478,7 +491,10 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
{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>
+19
View File
@@ -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";
@@ -188,6 +190,7 @@ export function Sidebar({
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -531,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}
+7 -1
View File
@@ -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}`} />
+13 -11
View File
@@ -30,6 +30,7 @@ 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. */
@@ -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}
/>
)}
+2 -18
View File
@@ -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
@@ -92,9 +92,6 @@ 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
@@ -172,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 = () => {
+55 -30
View File
@@ -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,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();
});
});
@@ -244,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());
@@ -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 () => {
+8 -2
View File
@@ -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,
@@ -880,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]);
@@ -1418,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) => {
@@ -1606,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.
+29 -6
View File
@@ -202,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>();
@@ -328,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) {
@@ -839,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
@@ -858,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";
@@ -267,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[]>([]);
@@ -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";
@@ -454,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);
});
+65 -10
View File
@@ -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>
*/
@@ -51,7 +54,15 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import {
type PaneGroups,
MAX_PANES,
detachSlot,
groupOf,
joinGroup,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -201,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));
@@ -215,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) => {
@@ -426,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));
@@ -439,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
+89 -32
View File
@@ -6,8 +6,8 @@
* 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 Proxy's models), plus Custom…;
@@ -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")}
+337 -271
View File
@@ -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}`)}
@@ -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();
});
});
@@ -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",
@@ -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))
@@ -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>
</>
);
}
@@ -50,7 +50,6 @@ describe("AppearanceSettings", () => {
"Apple Dark",
"Orca Dark",
"Apple Light",
"IntelliJ Light",
"Canvas",
"Orca Light",
]) {
@@ -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
});
});
+3 -2
View File
@@ -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;
+8 -9
View File
@@ -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
+53
View File
@@ -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];
}
+1 -1
View File
@@ -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";
+22
View File
@@ -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;
}
+4 -1
View File
@@ -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
View File
@@ -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;
+2 -1
View File
@@ -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.",
+27
View File
@@ -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)."
}
}
}
+1
View File
@@ -79,6 +79,7 @@
"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}}",
+4 -5
View File
@@ -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": {
+20
View File
@@ -823,6 +823,10 @@
"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",
@@ -942,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."
}
}
+2 -1
View File
@@ -258,7 +258,8 @@
"title": "Profile Claude Code",
"openButton": "Đổi profile",
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
"warning": "Hãy đóng các phiên Claude Code đang chạy trước khi đổi - phiên đang chạy sẽ ghi lại config khi thoát và đưa tài khoản cũ quay về.",
"warning": "Các phiên Claude Code đang chạy tự đi theo khi đổi: chúng nhận đăng nhập mới trong khoảng 30 giây.",
"mismatch": "Đăng nhập đang dùng thuộc tài khoản {{owner}}, nhưng config của Claude Code lại ghi {{config}}. Mọi request đang tính vào {{owner}}. Bấm Chuyển sang đúng tài khoản bạn muốn để sửa.",
"activeLabel": "Đang đăng nhập",
"noActive": "Chưa có tài khoản Claude nào đăng nhập trên máy này.",
"unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu lại, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.",
+27
View File
@@ -202,5 +202,32 @@
"message": "Các chỉnh sửa của bạn chưa được lưu và sẽ bị mất.",
"discard": "Bỏ thay đổi",
"keep": "Tiếp tục sửa"
},
"auth": {
"title": "Đăng nhập AgentHub",
"username": "Tên đăng nhập",
"password": "Mật khẩu",
"submit": "Đăng nhập",
"submitting": "Đang đăng nhập…",
"failed": "Sai tên đăng nhập hoặc mật khẩu",
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
"logout": "Đăng xuất",
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
},
"insights": {
"help": {
"sessions": "Một phiên là một cuộc trò chuyện với agent AI, từ lúc bắt đầu đến khi kết thúc.",
"agents": "Agent là các 'nhân công' AI: agent chính trong mỗi phiên cộng với các agent phụ nó tạo ra.",
"tokens": "Token là các mẩu chữ AI đọc và viết, khoảng 1 token ≈ 4 ký tự. Càng nhiều token càng tốn tiền.",
"cost": "Số tiền ước tính, tính từ số token và giá của từng mô hình.",
"events": "Mỗi bước AI làm (chạy công cụ, trả lời xong…) được ghi lại thành một sự kiện.",
"tokenMix": "Input = nội dung bạn và các file gửi cho AI. Output = nội dung AI viết ra. Cache read = nội dung dùng lại từ bộ nhớ đệm (rẻ hơn khoảng 10 lần). Cache write = nội dung lưu vào bộ nhớ đệm để dùng lại.",
"spendPace": "Tốc độ chi tiền, và cuối tháng sẽ tốn bao nhiêu nếu giữ tốc độ này.",
"toolCalls": "Số thao tác AI đã làm: đọc file, sửa file, chạy lệnh…",
"subagents": "Các agent phụ mà AI chính tạo ra để làm song song một phần công việc.",
"compactions": "Khi cuộc trò chuyện quá dài, nó được tóm tắt lại để có chỗ trống. Chi tiết ở đoạn đầu có thể bị mất.",
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
}
}
}
+1
View File
@@ -79,6 +79,7 @@
"quotaRefreshing": "Đang làm mới…",
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
"quotaFailed": "Không đọc được quota",
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
"quotaActive": "đang dùng",
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
+4 -5
View File
@@ -141,11 +141,10 @@
"output": "Output"
},
"drop": {
"hint": "Thả ảnh để đính kèm",
"uploading": "Đang tải ảnh lên…",
"failed": "Không đính kèm được ảnh",
"imagesOnly": "Chỉ thả được ảnh vào đây",
"remove": "Bỏ ảnh",
"hint": "Thả file hoặc ảnh để đính kèm",
"uploading": "Đang tải lên…",
"failed": "Không đính kèm được file",
"remove": "Bỏ đính kèm",
"view": "Xem ảnh"
},
"terminal": {
+20
View File
@@ -823,6 +823,10 @@
"reports": {
"title": "Báo cáo",
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
},
"users": {
"title": "Người dùng",
"description": "Tài khoản, mức dùng và nhật ký truy cập."
}
},
"backPrevious": "Quay lại",
@@ -942,5 +946,21 @@
"importBody": "Tùy chọn trong tệp sẽ thay thế tùy chọn của trình duyệt này, sau đó trang tải lại.",
"imported": "Đã nhập {{count}} tùy chọn. Đang tải lại…",
"badFile": "Tệp này không phải tệp tùy chọn của AgentHub."
},
"users": {
"title": "Người dùng",
"description": "Mọi tài khoản trên server này, cùng workspace, session và mức dùng gần đây.",
"name": "Người dùng",
"role": "Vai trò",
"workspaces": "Workspace",
"sessions": "Session",
"last7d": "7 ngày",
"last30d": "30 ngày",
"disabled": "đã khoá",
"tokens": "{{n}} token",
"manageHint": "Thêm, khoá hoặc sửa người dùng trên máy chủ: agenthub users …",
"auditTitle": "Nhật ký truy cập",
"auditDescription": "Mỗi lần admin hoặc analyst mở một session của member.",
"auditEmpty": "Chưa có lượt đọc nào."
}
}
+38 -95
View File
@@ -17,8 +17,7 @@
* 11-12 text (11 secondary, 12 primary)
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
* and terracotta, because a cool near-white page reads as glare. `green`/
* rising-lightness ramp) with IntelliJ Light's neutrals and blue accent. `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
@@ -29,37 +28,19 @@
/* Light theme — the CSS default, so it applies with no class on <html>.
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
page (the Settings appearance cards).
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
as it does in dark: the page is the DARKEST step and a card is brighter
than the page it sits on. The previous light block used the scale in its
native direction, which inverted the whole language — a near-white page
(glare) under grey cards that read as recessed, and every `hover:bg-*`
darkening instead of lifting.
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
A cool near-white ramp kept reading as glare no matter how far the page
step was pulled down, because the brightest thing on screen was also the
bluest. Cream lowers the perceived glare at the same measured lightness
and gives every neutral a warm cast to sit against. The whole ramp sits
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
white: at full brightness a large light panel still tires the eye. */
--surface-0: 232 229 218; /* #e8e5da — page background */
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
--border: 207 201 184; /* #cfc9b8 */
--border-light: 222 217 204; /* #ded9cc */
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer.
One more step down (#a34a28 → #924224) so `text-accent` also clears 4.5:1
on its own selected chip (`bg-accent/15–20`), not just on the card. */
--accent: 146 66 36; /* #924224 */
--accent-hover: 122 55 30; /* #7a371e */
IntelliJ Light's cool neutrals, ordered so lightness rises with the index
exactly as it does in dark: the page is the DARKEST step and a card is
brighter than the page it sits on, so every `hover:bg-*` lifts. */
--surface-0: 236 238 242; /* #eceef2 — page background */
--surface-1: 241 242 245; /* #f1f2f5 — sub-headers inside a card */
--surface-2: 247 248 250; /* #f7f8fa — inputs, chips, toolbars */
--surface-3: 253 253 254; /* #fdfdfe — `.card` */
--surface-4: 255 255 255; /* #ffffff — hover / active lift */
--surface-5: 255 255 255; /* #ffffff — topmost, rare */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
/* The FILL behind white text (`.btn-primary`, a Switch's on-track, a ticked
Checkbox, `bg-accent-solid`). In light themes the text accent is already
dark enough to carry white, so it simply IS the accent — resolved per
@@ -68,43 +49,37 @@
white text cannot sit on (3.3:1). */
--accent-solid: var(--accent);
--accent-solid-hover: var(--accent-hover);
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
sits a clear step below fg-secondary. */
--fg-muted: 104 99 89; /* #686359 */
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
over cream. The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
the card; these also clear it against the status's OWN densest chip (a
20% tint of itself — 4.5:1), which is where the label actually sits. */
--status-success: 22 104 66; /* #166842 */
--status-danger: 170 36 41; /* #aa2429 */
--status-warning: 133 79 0; /* #854f00 */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
/* Carries real content (timestamps, counts), so it is held above 4.5:1 on a
card and still sits a clear step below fg-secondary. */
--fg-muted: 103 106 120; /* #676a78 */
/* The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
/* Status labels clear 4.5:1 on the card AND on their own 20% chip. */
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
--tint-base: var(--surface-3);
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 4 120 87; /* emerald-700 #047857 */
--syn-number: 194 65 12; /* orange-700 #c2410c */
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
--syn-function: 180 83 9; /* amber-700 #b45309 */
--syn-operator: 190 24 93; /* pink-700 #be185d */
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
/* Syntax colours for `lib/highlight.ts` — IntelliJ Light's editor scheme. */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
/* A diff line edited in place (not wholly added or removed). */
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
@@ -309,38 +284,6 @@
--syn-del: 197 0 15; /* #c5000f */
}
[data-mode="light"][data-theme="intellij-light"] {
--surface-0: 236 238 242; /* #eceef2 */
--surface-1: 241 242 245; /* #f1f2f5 */
--surface-2: 247 248 250; /* #f7f8fa */
--surface-3: 253 253 254; /* #fdfdfe */
--surface-4: 255 255 255; /* #ffffff */
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 103 106 120; /* #676a78 */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
}
[data-mode="light"][data-theme="canvas"] {
--surface-0: 227 220 205; /* #e3dccd */
--surface-1: 232 226 212; /* #e8e2d4 */
@@ -0,0 +1,34 @@
/**
* @file laneChange.test.ts
* @description Tests for `laneChangeMatcher`: a lane_update fires only when the
* chosen key changed or a lane was removed, and each matcher remembers alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { laneChangeMatcher, runKeyOf } from "../laneChange";
import type { Lane, WSMessage } from "../types";
const upd = (lane: Partial<Lane>): WSMessage => ({
type: "lane_update",
data: { lane: { id: 1, run_id: null, status: "idle", session_id: null, ...lane } as Lane },
timestamp: "t",
});
describe("laneChangeMatcher", () => {
it("fires on a changed run key, a removal, and nothing else", () => {
const m = laneChangeMatcher(runKeyOf);
expect(m(upd({}))).toBe(true); // first sight
expect(m(upd({ last_event_at: "x" } as Partial<Lane>))).toBe(false);
expect(m(upd({ status: "running" }))).toBe(true);
expect(m(upd({ status: "running" }))).toBe(false);
expect(m({ type: "lane_update", data: { removed: 1 }, timestamp: "t" })).toBe(true);
expect(m({ type: "new_event", data: {}, timestamp: "t" } as WSMessage)).toBe(false);
});
it("keeps a separate memory per matcher", () => {
const a = laneChangeMatcher(runKeyOf);
const b = laneChangeMatcher(runKeyOf);
expect(a(upd({}))).toBe(true);
expect(b(upd({}))).toBe(true);
});
});
+84 -9
View File
@@ -5,7 +5,8 @@
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* per-workspace storage that must survive bad or stale data.
* dropping one tab onto another (joined in a fixed order: right, under the
* right pane, under the left pane), and the per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
@@ -17,6 +18,7 @@ import {
MAX_PANES,
closeInLayout,
detachSlot,
joinGroup,
layoutRects,
leaves,
loadGroups,
@@ -203,7 +205,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
const g: PaneGroups = {
groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }],
active: 0,
};
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
@@ -245,12 +250,18 @@ describe("pane groups (tabs of up to four panes)", () => {
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({
groups: [single(1), single(2)],
active: 0,
});
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 1,
};
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
@@ -258,7 +269,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)],
active: 1,
};
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
@@ -272,19 +286,36 @@ describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
const g: PaneGroups = {
groups: [
{ root: split("row", pane(0), pane(9)), focus: 9 },
{ root: pane(4), focus: 4 },
],
active: 1,
};
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
expect(loadGroups(2)).toEqual({
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }],
active: 0,
});
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(
EMPTY_GROUPS
);
const out = parseGroups({
groups: [
{ root: pane(1), focus: 1 },
{ root: pane(1), focus: 1 },
],
active: 1,
});
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
@@ -334,3 +365,47 @@ describe("parseLayout / storage", () => {
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
describe("joinGroup", () => {
const solo = (s: number): PaneLayout => ({ root: pane(s), focus: s });
const tabs = (...slots: number[]): PaneGroups => ({ groups: slots.map(solo), active: 0 });
it("joins tabs in order: side by side, under the right pane, under the left one", () => {
let g = tabs(0, 1, 2, 3);
g = joinGroup(g, [1], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), pane(1)));
g = joinGroup(g, [2], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
g = joinGroup(g, [3], 0);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(g).toEqual({ groups: [g.groups[0]], active: 0 });
expect(g.groups[0]?.focus).toBe(3);
});
it("activates the target tab and takes a pane out of a combo", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
expect(g.groups.map((l) => leaves(l.root))).toEqual([[0], [1, 2]]);
const h = joinGroup({ groups: [solo(0), combo], active: 1 }, [2], 0);
expect(h).toEqual({
groups: [
{ root: split("row", pane(0), pane(2)), focus: 2 },
{ root: pane(1), focus: 1 },
],
active: 0,
});
});
it("joins a whole combo, and refuses a fifth pane", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 1 }, [1, 2], 0);
expect(g.groups).toHaveLength(1);
expect(leaves(g.groups[0]?.root ?? null)).toEqual([0, 1, 2]);
const full = joinGroup(g, [], 0);
expect(full).toBe(g);
const four = joinGroup({ ...g, groups: [...g.groups, solo(3), solo(4)] }, [3], 0);
expect(joinGroup(four, [4], 0)).toBe(four);
});
});
+87 -2
View File
@@ -529,7 +529,53 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
async function request<T>(path: string, options?: RequestInit): Promise<T> {
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
workspaces: number;
sessions: number;
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
};
/** A staff read of a member's session (`GET /api/users/audit`). */
export type AuditEntry = {
id: number;
at: string;
method: string;
path: string;
session_id: string | null;
reader: string | null;
owner: string | null;
};
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "member" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
* fetch and one parsed result. Deleted as soon as it settles, so this is
* dedupe, never a cache. Callers must treat the result as read-only. */
const inflight = new Map<string, Promise<unknown>>();
function request<T>(path: string, options?: RequestInit): Promise<T> {
// Only a plain GET: a body, headers or an AbortSignal belongs to one caller.
const plainGet =
!options ||
(Object.keys(options).every((k) => k === "method") &&
(options.method ?? "GET").toUpperCase() === "GET");
if (!plainGet) return send<T>(path, options);
const hit = inflight.get(path);
if (hit) return hit as Promise<T>;
const p = send<T>(path, options).finally(() => inflight.delete(path));
inflight.set(path, p);
return p;
}
async function send<T>(path: string, options?: RequestInit): Promise<T> {
const token = dashboardToken();
// Build the effective header set. Order matters: defaults first so that the
// token and any caller headers can override, and caller headers land last.
@@ -544,6 +590,11 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
// Server mode: the login ended (expired, logged out elsewhere, disabled) —
// AuthGate listens and shows the login page again.
if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
@@ -605,6 +656,26 @@ export function gitTarget(laneId: number, session?: string | null): GitTarget {
}
export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: {
/** GET /api/auth/me — `{mode:"local"}`, or server mode with the user (null = logged out). */
me: () => request<AuthState>("/auth/me"),
/** POST /api/auth/login — sets the httpOnly login cookie. */
login: (username: string, password: string) =>
request<{ user: AuthUser }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
* whether the tracked upstream/origin remote is ahead. Backs the "update
@@ -1770,7 +1841,13 @@ export const api = {
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
*/
profiles: () =>
request<{ items: CcProfile[]; active: CcAccountIdentity | null }>("/cc-config/profiles"),
request<{
items: CcProfile[];
active: CcAccountIdentity | null;
/** Set when the live token belongs to a different account than the
* one `.claude.json` names; `active` is then the token's owner. */
mismatch?: { configEmail: string | null } | null;
}>("/cc-config/profiles"),
/**
* POST /api/cc-config/profiles - snapshot the logged-in account as a
* profile. The identifier and initial label are derived from the account;
@@ -2292,6 +2369,14 @@ export const api = {
body: file,
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. */
dropFile: (file: File) =>
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
+31
View File
@@ -0,0 +1,31 @@
/**
* @file laneChange.ts
* @description `lane_update` matcher for live-refresh callers that care about
* only a few lane columns. A lane is re-broadcast on every hook (last event,
* liveness…), so matching the bare message type made every such frame refetch;
* this one fires only when the chosen key of a lane actually changed, or when a
* lane was removed. Each call keeps its own memory of the keys it has seen.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { Lane, WSMessage } from "./types";
/** The columns a pane starting, ending or learning its session moves. */
export const runKeyOf = (l: Lane): string => `${l.run_id}:${l.status}:${l.session_id}`;
export function laneChangeMatcher(keyOf: (l: Lane) => string): (msg: WSMessage) => boolean {
const seen = new Map<number, string>();
return (msg) => {
if (msg.type !== "lane_update") return false;
const d = msg.data as { lane?: Lane; removed?: number };
if (d.removed !== undefined) {
seen.delete(d.removed);
return true;
}
if (!d.lane) return false;
const key = keyOf(d.lane);
if (seen.get(d.lane.id) === key) return false;
seen.set(d.lane.id, key);
return true;
};
}
+29
View File
@@ -327,6 +327,35 @@ export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups
return compact(groups, at);
}
/** Where a pane joining a tab lands: right of a lone pane, then under the
* right pane, then under the left one (2×2 at four). */
function joinLayout(layout: PaneLayout, slot: number): PaneLayout {
const shown = leaves(layout.root);
if (!layout.root) return solo(slot);
const [target, edge]: [number, Edge] =
shown.length === 1
? [shown[0] as number, "right"]
: [(shown.length === 2 ? shown[1] : shown[0]) as number, "bottom"];
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
}
/** Moves `slots` (a dropped tab, or one pane of a combo) out of their tabs
* into the tab at `to`, which becomes active. Same object back when nothing
* would move or the tab would pass `MAX_PANES`. */
export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroups {
const dst = g.groups[to];
if (!dst) return g;
const moving = slots.filter((s) => {
const from = groupOf(g, s);
return from >= 0 && from !== to;
});
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
const groups = g.groups.map((l, i) =>
i === to ? moving.reduce(joinLayout, l) : moving.reduce(closeInLayout, l)
);
return compact(groups, to);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;
+22 -9
View File
@@ -1,8 +1,8 @@
/**
* @file main.tsx
* @description Vite client entry point. Bootstraps React 18, loads global styles
* and i18n, registers the service worker for offline asset caching, and mounts
* {@link App} into `#root`.
* @description Vite client entry point. Bootstraps React 18, loads global styles,
* registers the service worker, prefetches the Workspace chunk on a `/workspace`
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
*
* ## Fonts
* Only Latin subsets of Inter and JetBrains Mono are imported so Vite bundles
@@ -63,9 +63,14 @@ import "@fontsource/jetbrains-mono/latin-500.css";
import "@fontsource/jetbrains-mono/vietnamese-400.css";
import "@fontsource/jetbrains-mono/vietnamese-500.css";
import App from "./App";
import "./i18n";
import { AuthGate } from "./components/AuthGate";
import { i18nReady } from "./i18n";
import "./index.css";
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
// locale fetch below, instead of after the first render asks for it.
if (location.pathname.startsWith("/workspace")) void import("./pages/Workspace");
if ("serviceWorker" in navigator) {
// Detect whether the page is already controlled by an SW *before* we
// register. On a truly fresh install there is no controller yet, so the
@@ -93,8 +98,16 @@ if ("serviceWorker" in navigator) {
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);
// The active locale is a lazy chunk: render once it is in, so no raw key ever
// paints. A failed fetch still renders (i18next falls back to the keys).
void i18nReady
.catch(() => undefined)
.then(() =>
createRoot(root).render(
<StrictMode>
<AuthGate>
<App />
</AuthGate>
</StrictMode>
)
);
+7 -3
View File
@@ -77,6 +77,7 @@ import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
import { useDataScope } from "../lib/dataScope";
@@ -111,7 +112,7 @@ const PAGE_SIZE = 50;
// Throttle live-event refreshes so a burst of hook events (e.g. a stream of
// PostToolUse results) triggers one refetch instead of dozens — and a steady
// stream still refreshes, which a trailing debounce never did.
const REFRESH_THROTTLE_MS = 1000;
const REFRESH_THROTTLE_MS = 3000;
/** The agents on screen not asked about yet, and the sessions to ask for them. */
export function agentsToFetch(
@@ -234,6 +235,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
useEffect(() => {
load();
}, [load]);
const [spinning, refresh] = useSpin(() => load());
// Agent labels for agents on screen not asked about yet — keyed by agent, so
// a subagent spawned later in a live session gets its label too.
@@ -401,12 +403,14 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button
onClick={() => load()}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
</div>
+15 -4
View File
@@ -4763,6 +4763,8 @@ function ProfilesModal({
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** `name` of the profile whose label is being edited inline, plus its draft. */
@@ -4787,6 +4789,7 @@ function ProfilesModal({
const r = await api.ccConfig.profiles();
setItems(r.items);
setActive(r.active);
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
@@ -4931,10 +4934,18 @@ function ProfilesModal({
<div className="px-4 py-3 border-b border-border space-y-2">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("profiles.warning")}</span>
</p>
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
{mismatchEmail && (
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
)}
</div>
<div className="overflow-auto p-4 space-y-3">
+48 -8
View File
@@ -95,8 +95,10 @@ import {
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { keepIfSame } from "../lib/keepIfSame";
import { StatCard } from "../components/StatCard";
import { AgentCard } from "../components/AgentCard";
import { useContextMenu } from "../components/ContextMenu";
@@ -778,13 +780,16 @@ function AnalyticsTab() {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope, tool, range]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
}, [load]);
// Heavy aggregates: event-driven reloads at most every 15 s.
useLiveRefresh(load, {
intervalMs: 15000,
throttleMs: 15000,
match: (msg) =>
msg.type === "session_created" ||
msg.type === "session_updated" ||
@@ -1064,8 +1069,13 @@ function AnalyticsTab() {
]}
/>
</div>
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
<button
onClick={refresh}
className="btn-ghost"
disabled={loading || spinning}
aria-busy={loading || spinning}
>
<RefreshCw className={`w-4 h-4 ${loading || spinning ? "animate-spin" : ""}`} />
{t("common:refresh")}
</button>
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
@@ -1092,6 +1102,7 @@ function AnalyticsTab() {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={data ? fmt(data.overview.total_sessions) : ""}
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
@@ -1102,6 +1113,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalAgents")}
help={t("common:insights.help.agents")}
value={data ? fmt(data.overview.total_agents) : ""}
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
@@ -1111,6 +1123,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("tokensExCacheReads")}
help={t("common:insights.help.tokens")}
value={data ? fmt(nonCacheReadTokens) : ""}
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
trend={
@@ -1127,6 +1140,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={costData ? fmtCost(costData.total_cost) : ""}
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
trend={
@@ -1140,6 +1154,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={data ? fmt(data.overview.total_events) : ""}
raw={data ? data.overview.total_events.toLocaleString() : undefined}
@@ -1326,7 +1341,12 @@ function AnalyticsTab() {
{/* Token mix donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.tokenMix")}
>
{t("tokenMix")}
</h3>
{tokenMixSegments.length === 0 ? (
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
) : (
@@ -1434,7 +1454,12 @@ function AnalyticsTab() {
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.spendPace")}
>
{t("spendPace")}
</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
@@ -2920,7 +2945,9 @@ export function Dashboard() {
if (sessionsRes) setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s])));
if (workingRes && waitingRes) {
const active = [...workingRes.agents, ...waitingRes.agents];
setActiveAgents(active);
// keepIfSame: an identical poll keeps the arrays, so the memoized cards
// do not re-render.
setActiveAgents(keepIfSame(active));
// Every subagent of the active main agents' sessions, in ONE call.
const activeSessionIds = [
...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)),
@@ -2929,10 +2956,11 @@ export function Dashboard() {
? await soft(api.agents.list({ session_ids: activeSessionIds, limit: 2000 }))
: { agents: [] };
if (seq !== monitorSeq.current) return;
if (subsRes) setAllSubagents(subsRes.agents.filter((a) => a.type === "subagent"));
if (subsRes) setAllSubagents(keepIfSame(subsRes.agents.filter((a) => a.type === "subagent")));
}
setError(failed[0] ?? null);
}, [scope]);
const [spinning, refresh] = useSpin(load);
const monitorOn = activeTab === "monitor";
useEffect(() => {
@@ -3054,8 +3082,14 @@ export function Dashboard() {
Analytics/Monitor/Health aside every time Monitor opened.
Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost flex-shrink-0"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
)}
{/* Tabs */}
@@ -3105,6 +3139,7 @@ export function Dashboard() {
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={stats ? fmt(stats.total_sessions) : ""}
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
@@ -3114,6 +3149,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeAgents")}
help={t("common:insights.help.agents")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-status-success"
@@ -3121,6 +3157,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeSubagents")}
help={t("common:insights.help.subagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-syntax-keyword"
@@ -3129,6 +3166,7 @@ export function Dashboard() {
/>
<StatCard
label={t("eventsToday")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
@@ -3138,6 +3176,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
@@ -3147,6 +3186,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={totalCost !== null ? fmtCost(totalCost) : ""}
raw={
totalCost !== null
+12 -5
View File
@@ -83,6 +83,8 @@ import {
} from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { workspaceHref } from "../components/SessionRowMenu";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
@@ -140,7 +142,7 @@ const EVENTS_INITIAL_BATCH = 50;
const EVENTS_MORE_BATCH = 500;
// Live-refresh bounds - see ActivityFeed for rationale.
const EVENTS_MAX_REFRESH = 500;
const EVENTS_REFRESH_THROTTLE_MS = 1000;
const EVENTS_REFRESH_THROTTLE_MS = 3000;
const DETAIL_TABS: DetailTab[] = ["agents", "conversation", "timeline"];
export function SessionDetail() {
@@ -249,8 +251,10 @@ export function SessionDetail() {
useEffect(() => {
probeRuns();
}, [probeRuns]);
// A pane starting/stopping re-broadcasts its lane (`lane_update`).
useLiveRefresh(probeRuns, { intervalMs: 10000, match: (msg) => msg.type === "lane_update" });
// A pane starting/stopping re-broadcasts its lane (`lane_update`); a hook's
// re-broadcast that moved no run column is ignored.
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
useLiveRefresh(probeRuns, { intervalMs: 10000, match: runChanged });
const load = useCallback(async () => {
if (!id) return;
@@ -272,6 +276,7 @@ export function SessionDetail() {
setLoading(false);
}
}, [id, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -672,12 +677,14 @@ export function SessionDetail() {
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
<button
onClick={load}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
+11 -5
View File
@@ -70,6 +70,8 @@ import {
import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { toast } from "../lib/toast";
@@ -237,6 +239,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
// the matching `sources` param.
}, [query, page, scope, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -253,11 +256,13 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
useEffect(() => {
loadDashboardRuns();
}, [loadDashboardRuns]);
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
useLiveRefresh(loadDashboardRuns, {
intervalMs: 15000,
// A pane starting/stopping re-broadcasts its lane (`lane_update`); the
// server sends no run_status any more. The 15 s poll covers extra slots.
match: (msg) => msg.type === "lane_update",
// A pane starting/stopping re-broadcasts its lane (`lane_update`) — only
// then, not on every hook's re-broadcast; the server sends no run_status
// any more. The 15 s poll covers extra slots.
match: runChanged,
});
useLiveRefresh(load, {
@@ -387,8 +392,9 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
>
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button onClick={refresh} disabled={spinning} aria-busy={spinning} className="btn-ghost">
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+15 -3
View File
@@ -120,6 +120,7 @@ import type { WSMessage } from "../lib/types";
import { confirmDialog } from "../lib/confirm";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import { useSpin } from "../hooks/useSpin";
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
@@ -249,6 +250,7 @@ export function Settings({
setLoading(false);
}
}, [t, needsInfo, section]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -263,7 +265,8 @@ export function Settings({
lastInfoAt.current = Date.now();
let timer: number | undefined;
const soon = () => {
if (timer !== undefined) return;
// Hidden tab: no reads; onVis catches up once when it is shown.
if (timer !== undefined || document.visibilityState !== "visible") return;
const wait = Math.max(0, INFO_MIN_MS - (Date.now() - lastInfoAt.current));
timer = window.setTimeout(() => {
timer = undefined;
@@ -275,6 +278,8 @@ export function Settings({
}, wait);
};
const beat = window.setInterval(soon, 30_000);
const onVis = () => soon();
document.addEventListener("visibilitychange", onVis);
const off = eventBus.subscribe((msg: WSMessage) => {
if (
msg.type === "session_created" ||
@@ -288,6 +293,7 @@ export function Settings({
});
return () => {
off();
document.removeEventListener("visibilitychange", onVis);
window.clearInterval(beat);
window.clearTimeout(timer);
};
@@ -810,8 +816,14 @@ export function Settings({
<FileDown className="w-3.5 h-3.5" />
{t("exportData")}
</a>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+30 -1
View File
@@ -41,6 +41,7 @@ import {
Palette,
Router,
Search,
UsersRound,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -64,6 +65,8 @@ import {
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { UsersSettings } from "../components/settings/UsersSettings";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
type Group = (typeof GROUPS)[number];
@@ -124,6 +127,15 @@ const MOVED: Record<string, string> = {
"ai-router": "ai-proxy",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set([
"data",
"reports",
"agents/claude-code",
"agents/codex",
"git-credentials",
]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
@@ -205,12 +217,15 @@ export function SettingsHub() {
};
}, [location.key, location.hash]);
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
});
const sections: Section[] = [
const allSections: Section[] = [
{
id: "data",
group: "general",
@@ -246,6 +261,17 @@ export function SettingsHub() {
...s("reports"),
render: () => <ReportSettings />,
},
...(isStaff
? [
{
id: "users",
group: "general" as const,
Icon: UsersRound,
...s("users"),
render: () => <UsersSettings />,
},
]
: []),
{
id: "notifications",
group: "general",
@@ -342,6 +368,9 @@ export function SettingsHub() {
render: () => <WorktreeVisibilitySettings />,
},
];
// Server mode: a member cannot open the host's own configuration or the
// all-users views (the server answers 403 EADMIN), so those pages are hidden.
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections;
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
+12 -7
View File
@@ -78,6 +78,7 @@ import { api } from "../lib/api";
import type { DateRange } from "../lib/dateRange";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
@@ -139,9 +140,9 @@ export function Workflows({
}, [fetchData]);
// Auto-refresh on the WebSocket messages that change workflow data, throttled
// so a steady stream refreshes every few seconds instead of never.
// so a steady stream refreshes every 15 s (heavy aggregates) instead of never.
useLiveRefresh(fetchData, {
throttleMs: 5000,
throttleMs: 15000,
match: (msg: WSMessage) =>
msg.type === "session_created" ||
msg.type === "session_updated" ||
@@ -150,10 +151,7 @@ export function Workflows({
msg.type === "workflow_upserted",
});
const handleRefresh = () => {
setLoading(true);
fetchData();
};
const [refreshing, handleRefresh] = useSpin(fetchData);
const handleExport = () => {
if (!data) return;
@@ -174,6 +172,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -197,6 +196,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -221,6 +221,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={lastUpdated}
/>
@@ -499,6 +500,7 @@ function PageHeader({
statusFilter,
onStatusFilterChange,
onRefresh,
refreshing = false,
onExport,
lastUpdated,
}: {
@@ -507,6 +509,7 @@ function PageHeader({
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
refreshing?: boolean;
onExport: () => void;
lastUpdated: Date | null;
}) {
@@ -566,10 +569,12 @@ function PageHeader({
{/* Actions */}
<button
onClick={onRefresh}
disabled={refreshing}
aria-busy={refreshing}
className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("refreshData")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`} />
</button>
<button
onClick={onExport}
+2 -1
View File
@@ -775,7 +775,8 @@ export function Workspace() {
useEffect(() => {
refreshList();
const tick = setInterval(() => {
refreshList();
// Hidden tab: skip; the visibility handler below re-reads on show.
if (document.visibilityState === "visible") refreshList();
}, 5000);
return () => clearInterval(tick);
}, [refreshList]);
@@ -157,6 +157,7 @@ exports[`screen snapshots > Activity feed 1`] = `
JSON
</button>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -3220,6 +3221,7 @@ exports[`screen snapshots > Dashboard 1`] = `
</div>
</div>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -3292,7 +3294,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="A session is one conversation with an AI agent, from start to finish."
>
Total Sessions
</span>
@@ -3339,7 +3342,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Agents are the AI workers: the main one in each session plus any helpers it started."
>
Total Agents
</span>
@@ -3405,7 +3409,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost."
>
Tokens (excl. cache reads)
</span>
@@ -3487,7 +3492,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Estimated money spent, calculated from token counts and each model's price."
>
Total Cost
</span>
@@ -3541,7 +3547,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
Total Events
</span>
@@ -5386,7 +5393,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
class="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title="How fast money is being spent, and where this month will end up if that speed continues."
>
Spend pace
</h3>
@@ -6018,6 +6026,7 @@ exports[`screen snapshots > Session detail 1`] = `
</button>
</div>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -6210,7 +6219,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
<span
class="text-fg-muted"
@@ -6232,7 +6242,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Events
<span
class="underline decoration-dotted underline-offset-2"
>
Events
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6244,7 +6258,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="How many actions the AI took: reading a file, editing, running a command…"
>
<span
class="text-syntax-keyword"
@@ -6266,7 +6281,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Tool calls
<span
class="underline decoration-dotted underline-offset-2"
>
Tool calls
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6278,7 +6297,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-property/20 bg-syntax-property/5 text-syntax-property"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Helper agents the main AI started to work on part of the task in parallel."
>
<span
class="text-syntax-property"
@@ -6316,7 +6336,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Subagents
<span
class="underline decoration-dotted underline-offset-2"
>
Subagents
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6328,7 +6352,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="When a conversation gets too long, it is summarized to make room. Early details can be lost."
>
<span
class="text-syntax-builtin"
@@ -6356,7 +6381,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Compactions
<span
class="underline decoration-dotted underline-offset-2"
>
Compactions
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6368,7 +6397,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Steps that failed. A few are normal; many may mean the AI got stuck."
>
<span
class="text-fg-muted"
@@ -6396,7 +6426,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Errors
<span
class="underline decoration-dotted underline-offset-2"
>
Errors
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6435,7 +6469,9 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Duration
<span>
Duration
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6830,6 +6866,7 @@ exports[`screen snapshots > Sessions 1`] = `
JSON
</button>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -7233,7 +7270,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.5.8 (rebuild client to match)
UI build v2.5.10 (rebuild client to match)
</p>
</div>
<div
@@ -7521,6 +7558,7 @@ exports[`screen snapshots > Settings — data 1`] = `
Export Data
</a>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -9857,6 +9895,7 @@ exports[`screen snapshots > Workflows 1`] = `
</button>
</div>
<button
aria-busy="false"
class="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title="Refresh data"
>
+6 -1
View File
@@ -13,9 +13,14 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach } from "vitest";
import "./i18n/index";
import { i18nReady } from "./i18n/index";
import i18n from "i18next";
// Locales load lazily in the app; tests get both up front so every
// `changeLanguage` (en or vi) switches synchronously, as before.
await i18nReady;
await i18n.loadLanguages(["en", "vi"]);
/** jsdom does not implement ResizeObserver — stub it for components that use it. */
// @ts-expect-error global stub
global.ResizeObserver = class {
+53 -6
View File
@@ -1,12 +1,13 @@
/**
* @file vite.config.ts
* @description Vite build and dev-server configuration for the dashboard client — React plugin, an API/WebSocket proxy that honours DASHBOARD_PORT, and build-time injection of the project version as `__APP_VERSION__`.
* @description Vite build and dev-server configuration for the dashboard client — React plugin, an API/WebSocket proxy that honours DASHBOARD_PORT, build-time injection of the project version as `__APP_VERSION__`, one chunk per UI language, hidden source maps, and pre-compressed `<file>.br`/`<file>.gz` siblings of every text asset ≥ 1 KB (served by the server for `/assets/`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig } from "vite";
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
// The dashboard's displayed version is the canonical project version from the
@@ -43,8 +44,43 @@ const APP_VERSION = resolveAppVersion();
// is what the embedded server in production binds to anyway.
const DASHBOARD_PORT = parseInt(process.env.DASHBOARD_PORT || "4820", 10);
// Writes `<file>.br` (quality 11) and `<file>.gz` (level 9) next to every text
// asset of 1 KB or more once the build is on disk, so the server can send the
// smallest encoding without compressing per request. node:zlib only.
function precompress(): Plugin {
let outDir = "dist";
const walk = (dir: string): string[] =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]
);
return {
name: "agenthub-precompress",
apply: "build",
configResolved(c) {
outDir = resolve(c.root, c.build.outDir);
},
closeBundle() {
for (const file of walk(outDir)) {
if (!/\.(js|css|svg|json|html)$/.test(file)) continue;
const buf = readFileSync(file);
if (buf.length < 1024) continue;
writeFileSync(
`${file}.br`,
brotliCompressSync(buf, {
params: {
[constants.BROTLI_PARAM_QUALITY]: 11,
[constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
},
})
);
writeFileSync(`${file}.gz`, gzipSync(buf, { level: 9 }));
}
},
};
}
export default defineConfig({
plugins: [react()],
plugins: [react(), precompress()],
define: {
__APP_VERSION__: JSON.stringify(APP_VERSION),
},
@@ -63,6 +99,17 @@ export default defineConfig({
},
build: {
outDir: "dist",
sourcemap: true,
// Maps are still written (for debugging a build), but no bundle points at
// them, so a browser never downloads them.
sourcemap: "hidden",
rollupOptions: {
output: {
// i18n/index.ts loads locale files lazily: one chunk per language.
manualChunks(id) {
const m = /[\\/]i18n[\\/]locales[\\/](\w+)[\\/]/.exec(id);
return m ? `locale-${m[1]}` : undefined;
},
},
},
},
});
+27 -6
View File
@@ -63,6 +63,8 @@ The server is **local-first** and is hardened to keep the dashboard off the netw
For deliberate LAN exposure, set `DASHBOARD_HOST` to a non-loopback address and list the names clients use in `DASHBOARD_ALLOWED_HOSTS`.
The routes that act on the machine (starting a session, creating a workspace, git writes, workspace sources, locks…) check the browser's `Origin` (`sameOriginGuard`). They accept a loopback origin, or an origin that **is** the request's own Host when that Host is one of `DASHBOARD_ALLOWED_HOSTS`. Before server mode they accepted loopback only, so a dashboard opened at a LAN name answered those writes with `403 EBADORIGIN`. Since then, the writes work from that LAN name too. Set `DASHBOARD_TOKEN` if the LAN is not trusted.
### Optional token (`DASHBOARD_TOKEN`)
Authentication is **off by default** (the loopback bind is the trust boundary). When `DASHBOARD_TOKEN` is set, every `/api/*` request **and** the WebSocket must present the token. It is strongly recommended whenever you bind beyond loopback. Pass it any of these ways:
@@ -73,6 +75,20 @@ Authentication is **off by default** (the loopback bind is the trust boundary).
These paths stay exempt even when a token is configured: `/api/health`, `/api/openapi.json`, `/api/docs`, and `/api/hooks` (local Claude Code hook ingestion). Requests that fail the check get `401` with error code `EUNAUTHORIZED`.
### Server mode login (`AGENTHUB_MODE=server`)
A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](SERVER-MODE.md)). Every `/api/*` call and WebSocket then needs the login cookie `agenthub_sid` (httpOnly, `SameSite=Strict`), except `/api/health` and `/api/auth/*`. A call without one answers `401` with code `EAUTH`. Hooks are accepted only from the host itself (`403 ELOCALONLY` otherwise). An `analyst` may only read (`403 EREADONLY`), and only an `admin` may write the host's configuration (`403 EADMIN`).
| Route | Answer |
| --- | --- |
| `GET /api/auth/me` | `{mode: "local"}`, or `{mode: "server", user: {id, username, role} \| null}` |
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], blockPercent}`. It never includes a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or the Settings About panel to confirm client and server builds match after deploy.
```mermaid
@@ -1152,7 +1168,7 @@ head of `toolFlow.transitions` (one window pass over the events for both).
The bundle scans every event, so it is computed in a worker thread on its own
read-only connection (`server/lib/read-pool.js`) and never stalls another
request, hook or websocket meanwhile. Identical requests in flight share one
run. `AGENTHUB_READ_WORKER=0` computes it inline instead.
run, and a result is reused for 30 s per distinct query. `AGENTHUB_READ_WORKER=0` computes it inline instead.
**Status codes:**
- **200** — the bundle, or the requested sections of it
@@ -1721,7 +1737,7 @@ sequenceDiagram
GET /api/events
```
Returns `{ events, limit, offset, total }`, newest first, filtered by `event_type`, `tool_name`, `agent_id`, `session_id` (each comma-separated), `q`, `from`/`to` (on `created_at`) and `sources`. Each row carries its labels, so a list page need not fetch every session and agent to name them: `session_name`, `session_cwd`, `session_lane_id`, `agent_name` and `agent_subagent_type` (each `null` when the session/agent row is missing). These columns are REST-only — the WebSocket `new_event` message is unchanged.
Returns `{ events, limit, offset, total }`, newest first, filtered by `event_type`, `tool_name`, `agent_id`, `session_id` (each comma-separated), `q`, `from`/`to` (on `created_at`) and `sources`. Each row carries its labels, so a list page need not fetch every session and agent to name them: `session_name`, `session_cwd`, `session_lane_id`, `agent_name` and `agent_subagent_type` (each `null` when the session/agent row is missing). These columns are REST-only — the WebSocket `new_event` message does not carry them (it carries the row `id`).
---
@@ -2039,7 +2055,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
#### Analytics
```http
@@ -2048,6 +2064,8 @@ GET /api/analytics?tz_offset=<minutes>&sources=<csv>&tool=<id>&from=<YYYY-MM-DD>
The Analytics bundle (`tokens`, `total_cost`, `tool_usage`, `daily_events`, `daily_sessions`, `agent_types`, `event_types`, `avg_events_per_session`, `total_subagents`, `overview`, `agents_by_status`, `sessions_by_status`). With `from`/`to` (same rules as above) the shape is unchanged and the numbers narrow: sessions and agents count by `started_at`, events by `created_at`, and `tokens` / `total_cost` come from the cost rows of the days in the range (`token_usage` has no date, so tokens follow the spend-day split). `daily_events` and `daily_sessions` keep their 365-day window.
The four numbers that scan `events` (`tool_usage`, `daily_events`, `event_types`, `avg_events_per_session`) are computed in the read worker (`server/lib/read-pool.js`) and reused for 10 s per distinct query (scope, range, `tz_offset`), so they may trail a new event by up to that long. A worker job past `AGENTHUB_READ_TIMEOUT_MS` answers **503** `EREADTIMEOUT`.
#### Spend Budgets
A budget is an alert rule of type `budget`, created and edited through `/api/alerts/rules`:
@@ -2363,13 +2381,15 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file.
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
**Who is logged in is decided by the TOKEN, not by `.claude.json`.** The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while `oauthAccount` still names another, and every request is then billed to the token's account. So `GET /profiles`, `POST /profiles`, `POST /profiles/activate` and `POST /profiles/usage` first ask Anthropic who owns the live token (`GET https://api.anthropic.com/api/oauth/profile`, `cc-usage.liveOwner`, cached per token — an access token never changes hands). `active` (and each item's `active`) is that owner; `mismatch` is `{ configEmail, configUuid }` when the config names someone else, else `null`. Offline, or with an expired live token, there is no owner and the config decides, as before.
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path.
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against `.credentials.json` and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against the live token's owner (the config's account only when the owner is unknown) and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
**Nothing calls this on a timer**, but opening the Profiles modal does ask for every saved account at once — comparing accounts is the only reason the panel exists, and a button per row made the user do that by hand. What keeps that affordable is the pair of rules above: the live account costs a plain read, and any other profile is refreshed only when its token has actually expired, which leaves it valid for hours of further opens. Each row resolves on its own, so one superseded account still lets its neighbours report. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `ESTALE` (409 — the refresh token was already spent by a newer login or refresh; activate the account once and save the profile again), `ELIVEEXPIRED` (409), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
@@ -2377,7 +2397,7 @@ A profile has two names, and the split is what lets a profile be called `Tài kh
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost. Under a `mismatch` that snapshot goes to the profile of the token's OWNER and replaces only its `credentials` (its label and identity stay) — never into the profile the config names, which would hand that profile another account's token — and it is taken even when the owner's profile is the switch target, so the switch writes the newest token that account has. It returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `EMISMATCH` (409 — `POST /profiles` while the live token belongs to another account than the config names; switch to that account's profile, or log in again), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A running Claude Code session follows a switch by itself: it re-reads the credentials store when the Keychain item's version or `.credentials.json`'s mtime changes (checked about every 30 s in Claude Code 2.1.x), so nobody has to close sessions first. If one still writes its old account back, the token-owner rule above shows the truth and a switch repairs it.
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
@@ -2425,6 +2445,7 @@ GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
POST /api/run/drop-file?name= Any raw file body (≤20 MB) → {path}: same dir, 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
GET /api/run/:id Run handle (returns live run state)
File diff suppressed because one or more lines are too long
+4
View File
@@ -101,6 +101,10 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `kill <pid>` stop command. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once) |
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `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]` | Server mode's accounts, written straight to the database; `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
### Interactive REPL
+38 -1
View File
@@ -609,6 +609,23 @@ CREATE TABLE report_runs (
CREATE INDEX idx_report_runs_report ON report_runs(report_id, id);
```
### users / auth_sessions
Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`). They are created on every install and stay empty in local mode.
| Table | Columns |
| --- | --- |
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns.
`dashboard_runs.pane_token_hash` is the SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a server-mode pane was started with; it is never returned by any route. `push_subscriptions.user_id` is the user whose browser a subscription is (`NULL` in local mode).
Users are never deleted, only disabled. Disabling a user or changing their password deletes their `auth_sessions` rows.
### lane_sources (and `lanes.instructions`)
A workspace's own context, as on its home page (`server/lib/lane-sources.js`). `lanes.instructions` (TEXT, nullable, ≤ 16 000 characters) is appended to every new pane's system prompt; `lane_sources` holds its extra **folders** (the user's directories, never deleted by AgentHub) and uploaded context **files** (AgentHub's own copies under `<data dir>/workspace-context/<lane id>/`, deleted with the row and with the workspace). Both are read at spawn from these rows only (`launchExtras`), never from a request. Both are additive migrations; the rows cascade when the lane row is deleted.
@@ -677,7 +694,7 @@ CREATE INDEX idx_agents_status ON agents(status);
-- tens of seconds to sub-second.
CREATE INDEX idx_events_agent_type ON events(agent_id, event_type);
-- A session's events in insert order. NOT a prefix of idx_events_session_type
-- A session's events in insert order. NOT a prefix of idx_events_session_type_tool
-- (not id-ordered): every hook's duplicate guard reads a session's latest event
-- through it. Dropping it (ad6e801) made that read sort the whole session per
-- hook and made a correlated "next tool event" query quadratic — minutes on a
@@ -687,6 +704,14 @@ CREATE INDEX idx_events_session ON events(session_id, id);
-- Per-tool counts (analytics, workflows) and the events filter's tool list
-- read this alone instead of every event row (~300 ms -> ~20 ms at 233k rows).
CREATE INDEX idx_events_tool ON events(tool_name);
-- A session's events by type and tool, covering: session stats (per-tool and
-- per-type counts), the hook's TurnDuration/APIError dedupe (one read per hook)
-- and the import's newest-per-type resume point read no event row
-- (1.2 s -> 20 ms on a 29K-event session). Replaces idx_events_session_type
-- (session_id, event_type), dropped at startup, so a hook writes no more index
-- entries than before.
CREATE INDEX idx_events_session_type_tool ON events(session_id, event_type, tool_name, created_at);
```
### tool_executions Indexes
@@ -872,8 +897,20 @@ db.pragma("foreign_keys = ON");
db.pragma("busy_timeout = 5000"); // two dashboards may share the file
db.pragma("synchronous = NORMAL"); // safe with WAL: a power cut loses only the last commits, never the file
db.pragma("journal_size_limit = 67108864"); // a checkpoint truncates the WAL back to 64 MB
db.pragma("cache_size = -65536"); // 64 MB page cache (default 2 MB)
db.pragma("mmap_size = 268435456"); // memory-mapped reads, 256 MB
```
The read worker (`server/lib/read-pool.js`) sets the same `cache_size`/`mmap_size`.
**Checkpoints.** The running server sets `wal_autocheckpoint = 0` on its main
connection and checkpoints every `DASHBOARD_WAL_CHECKPOINT_MS` (default 5000, `0` =
SQLite's automatic checkpoint as before) with `wal_checkpoint(PASSIVE)` from the read
worker's own writable connection. SQLite's automatic checkpoint runs inside whichever
`COMMIT` crosses 1000 WAL pages — a hook's, on the event loop — and measured up to 2.7 s
per commit under load. A failed checkpoint switches the automatic one back on. A second
process on the same file (another dashboard, the CLI) keeps its own setting.
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Nothing runs `VACUUM`: it rewrites the whole file under an exclusive lock, so space freed by a cleanup is reused for new rows instead of returned to the disk.
**Growth.** `events.data` is the hook payload TRIMMED by `trim_event_data(?)` (`server/lib/event-payload.js`, a SQL function registered on the connection in `server/db.js`, used by every event `INSERT` and by the duplicate guard): Edit/MultiEdit/NotebookEdit/Write drop `tool_response.originalFile`/`oldString`/`newString` (Write also `content`), Read drops `tool_response.file.content`/`base64`, and any string inside `tool_input`/`tool_response` over 8 KB keeps its first 6 KB and last 2 KB; `structuredPatch` and every field outside those two are kept, and `_trimmed` lists what was dropped. The full text stays in the session transcript and AgentHub's snapshot of it under `<data dir>/transcripts/`, which the Conversation tab reads. Rows stored before this are trimmed once in the background (`sweepEventPayloads`, 200 rows per transaction, `user_version` 4 after marker 3); measured on a 1.2 GB install it took `events.data` from 913 MB to ~400 MB. Freed pages are reused, not returned to the disk (no `VACUUM`). No ROW is pruned automatically. The only deletions are the user's: Settings → clear data / cleanup, a workspace's purge, `scripts/clear-data.js`. Clear data runs in ONE transaction (foreign keys off around it, so workflows are deleted explicitly), cleanup deletes in 500-id chunks inside one transaction. `GET /api/settings/export` streams `events` in keyset pages of 500 instead of loading the table.
+18 -9
View File
@@ -263,8 +263,9 @@ AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~
# Pricing and saved settings
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
AGENTHUB_READ_WORKER= # 0 runs heavy aggregates (GET /api/workflows) inline instead of in the read worker (default worker)
AGENTHUB_READ_WORKER= # 0 runs heavy aggregates (GET /api/workflows, /api/analytics' event scans) inline instead of in the read worker (default worker)
AGENTHUB_READ_TIMEOUT_MS=60000 # a read-worker job past this answers 503 EREADTIMEOUT and gets a fresh worker
DASHBOARD_WAL_CHECKPOINT_MS=5000 # WAL checkpoint from the read worker instead of inside a hook's COMMIT (0 = SQLite's automatic one)
AGENTHUB_SETTINGS_FILE= # where dashboard-saved settings persist (default ~/.config/agenthub/settings.env);
# load order: real env > the checkout's .env > this file
@@ -726,14 +727,22 @@ node --optimize-for-size server/index.js
### SQLite Tuning
```javascript
// server/db.js - Add these pragmas
db.pragma('journal_mode = WAL'); // Write-Ahead Logging
db.pragma('synchronous = NORMAL'); // Faster writes
db.pragma('cache_size = -64000'); // 64MB cache
db.pragma('temp_store = MEMORY'); // Temp tables in memory
db.pragma('mmap_size = 30000000000'); // Memory-mapped I/O
db.pragma('page_size = 4096'); // Optimal page size
`server/db.js` already sets WAL, `synchronous = NORMAL`, a 64 MB `cache_size` and a
256 MB `mmap_size`, and the server checkpoints the WAL from its read worker
(`DASHBOARD_WAL_CHECKPOINT_MS`) instead of inside a hook's `COMMIT` — see
[DATABASE.md § SQLite Pragmas](DATABASE.md#sqlite-pragmas). Nothing to add by hand.
### Compression
The client build writes a `.br` and a `.gz` beside every hashed asset, and the
server sends the one the browser accepts (`Content-Encoding`, `Vary: Accept-Encoding`)
— a first Workspace load is ~75 % smaller with no compression CPU at request time.
API responses are not compressed by AgentHub; behind nginx, add:
```nginx
gzip on;
gzip_types application/json;
gzip_min_length 1024;
```
### Nginx Tuning
+1 -1
View File
@@ -407,7 +407,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2); a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
+1
View File
@@ -45,6 +45,7 @@ graph TB
| [client/README.md](../client/README.md) | React frontend architecture, components, state management | Frontend developers |
| [server/README.md](../server/README.md) | Express backend, database, WebSocket, API | Backend developers |
| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (including `run_stream` / `run_status` / `run_input_ack` for the Run page) | Integration developers |
| [SERVER-MODE.md](./SERVER-MODE.md) | Server mode: logins and roles for a dashboard shared over the web (`agenthub server setup`), reverse proxy setup | Operators |
| [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators |
| [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers |
| [MCP.md](./MCP.md) | The read-only MCP server (`agenthub mcp`): setup and tool reference | MCP integrators |
+248
View File
@@ -0,0 +1,248 @@
# Server mode
AgentHub has one codebase and two ways to run it:
| | Local mode (default) | Server mode |
| --- | --- | --- |
| Who uses it | You, on your own machine | Several people over the web |
| Login | None (the loopback bind is the trust boundary) | Every page, API call and WebSocket needs one |
| Turn on | Nothing to do: every install starts here | `agenthub server setup` |
| Extra code loaded | None: `/api/auth/me` answers `{mode: "local"}` and nothing else changes | `server/lib/auth.js`, the login page |
Local mode does not change at all. The login code stays dormant until
`AGENTHUB_MODE=server` is set.
> **What server mode separates, and what it does not.** In the dashboard and
> its API, each member sees only their own workspaces and the sessions,
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
> Every terminal pane, though, still runs as the one Linux account that runs
> the dashboard. An agent in a pane can therefore read every user's folders,
> transcripts and the database straight from disk. Use server mode only with
> people you trust until phase S3 (one Linux user per account) lands. S3 and S4
> (shared tools) need `sudo` on the host and are planned in the AgentHub-Server
> repo.
## Set it up
On the machine that will host the dashboard, logged in as the account that
runs it:
```bash
# 1. Turn server mode on, allow the public name, and create the first admin
agenthub server setup --hostname agenthub.example.com --admin boss
# (asks for the admin's password twice; from a script: pipe it on stdin)
# 2. Restart the dashboard so it picks the mode up
systemctl --user restart agenthub # or: agenthub start
# 3. Add the other people
agenthub users add alice # member
agenthub users add carol --role analyst # read-only
```
`setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into
AgentHub's settings file (`~/.config/agenthub/settings.env`). The real
environment and a checkout's `.env` still take priority over that file. Run
setup again whenever you need to: it creates an admin only when none exists.
## Put HTTPS in front of it
Keep the dashboard on its default loopback bind (do **not** set
`DASHBOARD_HOST`) and serve it through a reverse proxy on the same machine.
The proxy has to:
- pass the original `Host` (the dashboard checks it against
`DASHBOARD_ALLOWED_HOSTS` and compares it with the browser's `Origin`);
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
- set `X-Forwarded-For` and `X-Forwarded-Proto`.
nginx:
```nginx
server {
listen 443 ssl;
server_name agenthub.example.com;
ssl_certificate /etc/ssl/agenthub.crt;
ssl_certificate_key /etc/ssl/agenthub.key;
location / {
proxy_pass http://127.0.0.1:4820;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
}
}
```
The login cookie gets `Secure` when the request came over HTTPS
(`X-Forwarded-Proto: https`).
## Roles
| Role | Can |
| --- | --- |
| `admin` | Everything, including the host's own configuration and every user's data |
| `member` | Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration |
| `analyst` | Reads everything (usage, prompts, every user's sessions); no terminal, no write |
"The host's configuration" means a write (anything but GET/HEAD) under
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/ai-proxies`,
`/ai-routers`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`,
`/budgets`, `/webhooks`, `/remote-sources` and `/reports`
(`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A member who tries one gets
`403 EADMIN`. An analyst who tries any write gets `403 EREADONLY`.
## What each user sees
Every workspace has an owner: the user who created it (`lanes.owner_id`).
Workspaces made before server mode, or by the host account, have none.
- A session belongs to the workspace that owns it: `sessions.lane_id`, or the
workspace whose pane holds it. Its agents, events, tokens, cost, alerts,
snapshots and workflow runs belong to that session.
- A **member** sees only rows of their own workspaces, everywhere: lists,
counts, charts, cost, the Sessions/Events/Agents tables, exports, live
WebSocket updates and terminals. A request naming someone else's row (in the
path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row
that does not exist (`scopeGuard`, `server/lib/scope.js`). Sessions with no
workspace, such as ones started in a terminal outside AgentHub, are not shown
to members.
- An **admin** or **analyst** sees everything. Each time one of them opens a
session a member owns (its page, transcript, events or cost), the read is
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days.
A member also cannot open the host's own configuration, or any view across all
users: Claude Code / Codex config (except the command and skill lists the
prompt box uses), git credentials, remote sources, webhooks, reports, metrics,
imports, data export, alert rules and `/api/users`. Those return
`403 EADMIN`, and Settings hides them for members.
### Keep members' folders apart
```bash
# in ~/.config/agenthub/settings.env
AGENTHUB_USERS_ROOT=/srv/agenthub/users
```
With this set, a member can create a workspace only inside
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. A folder
that already belongs to another user's workspace gets `409 EOWNED`. Admins are
not limited. Create each user's folder yourself. Until S3 this keeps workspaces
apart, not agents.
## What the gate does
- `/api/health` and `/api/auth/*` answer without a login. Everything else
under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the
login page. Paths are checked the way Express routes them: decoded, with
repeated slashes collapsed, and case-insensitive.
- Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket
with no forwarding header. A request through the proxy gets
`403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token.
- Every write (any method but GET/HEAD) that carries an `Origin` or `Referer`
must name the dashboard's own allowed host, or loopback. Anything else gets
`403 EBADORIGIN`. This is on top of the `SameSite=Strict` cookie, which a
sibling subdomain would still satisfy.
- WebSockets need a login, and an `Origin` that is the request's own allowed
Host. A terminal (`/ws-pty/*`) is refused to an analyst and to anyone but the
owner of the pane's workspace. Open sockets are re-checked: `/ws` every 30 s,
terminals every 10 s (`AGENTHUB_PTY_RECHECK_MS`). A logout, a disable, a
password change, or a demotion to analyst closes them.
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
change and disabling the user each end it.
- Passwords are stored as scrypt hashes and must be at least 8 characters.
Every login attempt counts before its password is checked, so attempts sent
in parallel count too. After 10 failed logins from one address, or 30 for one
username, in 15 minutes, further logins answer `429 ETOOMANY` until the
window passes. The counters are kept in memory, so a restart resets them.
- **Inside a pane**, the `agenthub` CLI, the MCP server and the statusline act
as the owner of the pane's workspace. Each pane the dashboard starts gets its
own `AGENTHUB_PANE_TOKEN` through tmux `-e`, and the database stores only its
hash (`dashboard_runs.pane_token_hash`). The token is accepted only from this
machine and only while that pane is alive, as header `x-agenthub-pane-token`.
A workspace with no owner has no such user. Give old workspaces an owner with
`agenthub users assign <workspace id> <user>`.
- Named locks (`agenthub lock …`) are per user: a member's lock names live in
their own namespace, so one user's agents never see or take another's.
- Browser push subscriptions belong to the user who made them. A
notification goes only to that user's own browsers, never to the host's
desktop.
- A member cannot create a session by hand (`POST /api/sessions` → `403`), and
cannot change a workspace's pane or session columns: `PATCH` accepts only
`title`, `pipeline`, `notes`, `links` and `branch` (`403 EFIELD`).
Pet uploads are admin-only.
- The native file picker stays local-only (`EREMOTE`) and every page falls back
to its folder browser.
## Accounts
Accounts are managed from the host's shell, never from a web route:
```bash
agenthub users list
agenthub users add <name> [--role admin|analyst|member]
agenthub users passwd <name> # ends that user's logins
agenthub users role <name> <role>
agenthub users disable <name> # ends that user's logins; users are never deleted
agenthub users enable <name>
agenthub users assign <workspace id> <name> # give a pre-server-mode workspace an owner
agenthub users audit [--limit N] # who read which member's session
agenthub server status
```
## Claude account pool
An admin can sign every Claude account the company has into AgentHub. Each new
Claude session then runs on the account with the most room left, and nobody has
to log in or switch accounts by hand. The pool works in local mode too, and it
stays empty (changing nothing) until you add an account.
```bash
# Once per account: sign in as that account and make a one-year token
claude setup-token
# Add it to the pool (the token is typed hidden, or piped on stdin)
agenthub accounts add team-1
agenthub accounts list # live panes, $ in the last 5 h / 7 d, quota, state
agenthub accounts remove team-1
```
How a new session picks its account (`server/lib/claude-pool.js`):
1. An account whose last quota reading is at or above 90% in the 5-hour or
weekly window goes last. It is still used if every account is that full.
2. Next, the account with the fewest live panes, so concurrent sessions spread out.
3. Then the account with the least $ spent in the last 5 hours, then the last
7 days. AgentHub sums this from the usage of the sessions it started on each
account (`dashboard_runs.account`).
- The pane gets the token through tmux `-e CLAUDE_CODE_OAUTH_TOKEN=…`. That
token outranks the machine's own `/login` for that process only.
- A session on an AI Proxy keeps the proxy's key and does not use the pool.
- A token more than a year old is skipped.
- A resumed session picks an account again, like a new one.
- Quota is read with each account's own token, in the background, at most every
5 minutes. If the usage endpoint refuses a setup-token, the state column shows
`no quota (…)` and ranking uses spend alone.
- `GET /api/accounts` returns the same table, without tokens.
> **Ceiling:** anyone with a terminal in AgentHub can read the token of the
> account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`), and, as one
> Linux user, every file of the pool. Hiding tokens from panes needs one Linux
> user per account (phase S3), or AgentHub acting as the pane's API endpoint.
## Turn it off
```bash
agenthub server off
systemctl --user restart agenthub
```
Accounts are kept, so `agenthub server setup` turns server mode back on with
the same users.
+52
View File
@@ -0,0 +1,52 @@
# Performance pass — 10+ concurrent users, lean local mode
Audit 2026-10-07 against a copy of the real DB (1.2 GB, 253K events). Baseline,
10 simulated browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms, request
p95 ~10 s, 266 failed. Bench tools: `/tmp/ahperf/tools/` (`clients.js`, `hookload.js`,
`bench.js`). Target: ELD p99 < 100 ms, request p95 < 300 ms, 0 failed.
Dev (4830) and prod (4820) keep sharing one `dashboard.db`.
## Server
- [x] S1 `/api/workflows/skill-usage`: `+event_type` + range instead of LIKE (784 ms → ~5 ms)
- [x] S2 `/api/analytics` into `read-pool.js` JOBS + short memo keyed by scope/args
- [x] S3 `/api/workflows` result memo (30 s)
- [x] S4 `idx_events_session_type_tool` replaces `idx_events_session_type` (partial index skipped: the 30 s workflows memo covers it); `cache_size`/`mmap_size`
- [x] S5 WAL: `wal_autocheckpoint=0` on the main connection, PASSIVE checkpoint off-thread, fallback
- [x] S6 tmux: 1 s `list-sessions` memo (cleared by this process's new/kill), one `liveSessions()` per `GET /api/lanes`, attach options chained (4 spawns → 2), async `ps` for the watchdog, heal tick skipped when nothing runs
- [x] S7 hooks: hoist TurnDuration/APIError statements + watermark; one `listLanes()` per hook; hourly upsert only on change
- [x] S8 hooks.js `session_id` rebind skipped for slot 1–9 panes
- [x] S9 startup subagent backfill: do not re-parse unstampable files every boot
- [x] S10 pty-attach backpressure
- [x] S11 serve pre-compressed `.br`/`.gz` assets
- [x] S12 broadcasts only after the hook transaction commits
- [x] S13 `new_event` carries `id`
- [~] S14 codex-usage memo — DROPPED: an in-memory "already read" map survives Clear data and blocks the re-import (caught by codex-usage tests); the pass costs ~5 ms per 15 s
- [x] S15 transcript newline scan via `indexOf`
## Client
- [x] C1 no WS-triggered refetch in hidden tabs; hidden-tab pollers paused; drop hourly `/updates/check`
- [x] C2 throttle WorkspaceTree / WorkspaceSessionList / `/api/run` refetchers; lazy notification lookup
- [x] C3 WS reconnect jitter; terminal retry backoff
- [x] C4 heavier throttles for aggregates / detail refetches; Monitor `keepIfSame`; GET in-flight dedupe
- [x] C5 build: pre-compress assets, `sourcemap: "hidden"`
- [x] C6 load only the active locale; lazy routes/Sagi/modals/xterm
- [x] C7 Sagi re-render fixes; service-worker asset cache
## Verify
- [x] `npm run test:server` (2556/2556), `npm run test:client` (1084/1084), `tsc -b`, header check; headless Chrome smoke (en + vi, 4 pages, terminal attach)
- [x] re-run the 10-client bench (server side, same old-client simulation) — see Result
## Result (server, `clients.js 10 90 10`, same simulated old client)
| | before | after |
|---|---|---|
| event-loop delay p99 / max | 1239 ms / 2.0 s | 31.8 ms / 94 ms |
| failed requests | 266 | 0 |
| requests served | 74/s | 111/s |
| hook p95 / p99 (10/s) | — | 7.6 / 28.8 ms |
| RSS | 633 MB | 346 MB |
Biggest single cause after the first round: 2,247 synchronous `tmux list-sessions`
from `GET /api/run` = 60 s of a 90 s run blocked (fork of a 600 MB process); the 1 s
memo removed it. The client fixes (C1–C4) cut the request rate itself on top.
+14
View File
@@ -0,0 +1,14 @@
# Server mode — working checklist
Goal (2026-10-07): finish the server part — S2 ownership as proposed, review it.
- [x] S1 login, roles, `agenthub server|users` (uncommitted)
- [x] Claude account pool + balancer (uncommitted)
- [x] S2 core: `lanes.owner_id`, `server/lib/scope.js` (predicates, `scopeGuard`, broadcast audience, folder root), STAFF_ONLY_PREFIXES, pty attach check, heartbeat re-check
- [x] S2 routes: aggregates, rows, workflows (+ read worker), workspaces/runs — 4-agent workflow, each with its own test file
- [x] Bypass fix: `apiPath()` canonical path + case-insensitive rules (found by the workspaces agent)
- [x] S6 basics: `GET /api/users`, audit log (`audit_log`, `agenthub users audit`), Settings → Users, members' Settings hides staff pages
- [x] Adversarial review (5 lenses, 21 findings, each verified) and fixes: lane id coercion (1e1), PATCH run_id, array ids, POST agents/sessions, transcript path traversal (all modes), push per user, pool/quota leaks, pty re-check, CSRF origin on writes, parallel login throttle, overlapping folders, pane credential for CLI/MCP/statusline, lock namespace + lock-name path traversal (all modes), AuthGate retry/cache
- [x] Full suites: server 2532/2535 (3 lane-detect fail on a foreign process holding port 3000, same on clean HEAD), client 1074/1074; browser e2e member vs admin; docs synced
- [x] Upgrade-impact review (2026-10-07): old vs new API diff on a copy of the real DB (local mode identical but for noise), old+new builds sharing one migrated DB, 5-lens review + verify. Fixed: lock names (any single dir name ≤255, holders with spaces normalised, CLI fails fast on non-409), AuthGate rendering on an older server / DASHBOARD_TOKEN 401, pane_token_hash kept out of exports; documented the LAN origin change. Suites: server 2536/2536, client 1078/1078
- Blocked on sudo / a real host: S0 spike, S3 (one Linux user per account), S4 (shared /opt tools)
+3
View File
@@ -164,6 +164,9 @@ async function callApi(pathname) {
const headers = process.env.DASHBOARD_TOKEN
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
: {};
// Server mode: inside a dashboard pane, act as the workspace's owner.
if (process.env.AGENTHUB_PANE_TOKEN)
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
let res;
try {
res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
+264 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.5.8
version: 2.5.10
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -62,6 +62,8 @@ tags:
description: Web Push notification subscriptions and broadcast (VAPID); also fires native Electron notifications when hosted in the desktop app
- name: AI Tools
description: The catalog of AI coding CLIs a lane can run (server/data/ai-tools/*.json + DASHBOARD_AI_TOOLS_DIR), what is installed on PATH, and the user's enable/default choice
- name: Auth
description: 'Server mode''s login (AGENTHUB_MODE=server). In local mode — the default — only GET /api/auth/me answers, with {mode: "local"}.'
components:
parameters:
SessionIdPath:
@@ -4741,6 +4743,40 @@ components:
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
items:
$ref: '#/components/schemas/DashboardEvent'
AuthUser:
type: object
required:
- id
- username
- role
properties:
id:
type: integer
username:
type: string
role:
type: string
enum:
- admin
- analyst
- member
disabled:
type: boolean
AuthMe:
type: object
required:
- mode
properties:
mode:
type: string
enum:
- local
- server
user:
description: 'Server mode only: the logged-in user, or null.'
nullable: true
allOf:
- $ref: '#/components/schemas/AuthUser'
paths:
/api/health:
get:
@@ -13334,6 +13370,233 @@ paths:
error:
code: WORKFLOW_DETAIL_FAILED
message: database is locked
/api/users:
get:
tags:
- Auth
operationId: listServerUsers
summary: Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a member)
responses:
'200':
description: Accounts
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
allOf:
- $ref: '#/components/schemas/AuthUser'
- type: object
properties:
workspaces:
type: integer
sessions:
type: integer
last7d:
type: object
properties:
cost:
type: number
tokens:
type: integer
last30d:
type: object
properties:
cost:
type: number
tokens:
type: integer
'403':
description: EADMIN — a member
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/audit:
get:
tags:
- Auth
operationId: listAuditLog
summary: Admin/analyst reads of sessions members own, newest first
parameters:
- name: limit
in: query
schema:
type: integer
maximum: 1000
responses:
'200':
description: Entries
content:
application/json:
schema:
type: object
properties:
entries:
type: array
items:
type: object
properties:
id:
type: integer
at:
type: string
method:
type: string
path:
type: string
session_id:
type: string
nullable: true
reader:
type: string
nullable: true
owner:
type: string
nullable: true
'403':
description: EADMIN — a member
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/accounts:
get:
tags:
- Auth
operationId: listPoolAccounts
summary: The Claude account pool as the balancer sees it (never a token)
parameters:
- name: force
in: query
schema:
type: string
enum:
- '1'
description: Re-read every account's quota first
responses:
'200':
description: Accounts with live panes, recent spend and quota
content:
application/json:
schema:
type: object
properties:
blockPercent:
type: number
accounts:
type: array
items:
type: object
properties:
name:
type: string
addedAt:
type: string
expiresAt:
type: string
expired:
type: boolean
livePanes:
type: integer
spend5h:
type: number
spend7d:
type: number
quota:
type: object
nullable: true
quotaPercent:
type: number
nullable: true
full:
type: boolean
/api/auth/me:
get:
tags:
- Auth
operationId: authMe
summary: The dashboard's mode and, in server mode, who is logged in
responses:
'200':
description: Mode and user
content:
application/json:
schema:
$ref: '#/components/schemas/AuthMe'
/api/auth/login:
post:
tags:
- Auth
operationId: authLogin
summary: Log in; sets the httpOnly, SameSite=Strict cookie agenthub_sid
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- username
- password
properties:
username:
type: string
password:
type: string
responses:
'200':
description: Logged in
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
'401':
description: EBADLOGIN — wrong username or password, or a disabled user
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EBADORIGIN — a cross-origin request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTSERVER — the dashboard is in local mode
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — too many failed logins from this address (15 minutes)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/auth/logout:
post:
tags:
- Auth
operationId: authLogout
summary: End this login and clear its cookie
responses:
'200':
description: Logged out
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
/api/settings/import:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.5.8",
"version": "2.5.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.5.8",
"version": "2.5.10",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.5.8",
"version": "2.5.10",
"bin": {
"agenthub": "bin/agenthub.js"
},
+37 -1
View File
@@ -2652,6 +2652,22 @@ async function scanAndImportSubagents(dbModule, sessionId, transcriptPath, opts
return { imported: subFiles.length, created, reparented };
}
/** Count, total size and newest mtime of a session's subagent files. */
function subagentFilesFingerprint(files) {
let size = 0;
let newest = 0;
for (const f of files) {
try {
const st = fs.statSync(f);
size += st.size;
newest = Math.max(newest, st.mtimeMs);
} catch {
/* vanished between listing and stat */
}
}
return `${files.length}:${size}:${Math.round(newest)}`;
}
/**
* Backfill metadata.tokens onto existing subagent rows that predate per-agent
* cost tracking. New imports and live SubagentStop scans stamp each subagent's
@@ -2667,7 +2683,11 @@ async function scanAndImportSubagents(dbModule, sessionId, transcriptPath, opts
* completely untouched; only the per-agent breakdown is filled in.
*
* Idempotent and self-limiting: once a row has a tokens key it drops out of the
* driving query, so steady-state startups do near-zero work.
* driving query, so steady-state startups do near-zero work. A row that can
* never be stamped (its file holds no usage) would keep its session in the
* query forever, so a session's subagent files are fingerprinted (count, size,
* newest mtime) into `metadata.subagent_tokens_checked` after a pass and are
* not parsed again until they change — measured 2–5 s of every boot before.
*/
async function backfillSubagentTokenMetadata(dbModule) {
const { db } = dbModule;
@@ -2721,6 +2741,14 @@ async function backfillSubagentTokenMetadata(dbModule) {
continue;
}
if (!subFiles || subFiles.length === 0) continue;
const fingerprint = subagentFilesFingerprint(subFiles);
let checked = null;
try {
checked = s.meta ? JSON.parse(s.meta).subagent_tokens_checked : null;
} catch {
/* unreadable metadata: treat as never checked */
}
if (checked === fingerprint) continue; // nothing changed since the last pass
scanned++;
const mainAgentId = `${s.session_id}-main`;
for (const file of subFiles) {
@@ -2733,6 +2761,14 @@ async function backfillSubagentTokenMetadata(dbModule) {
/* non-fatal — partial or unrelated files are common */
}
}
try {
db.prepare(
`UPDATE sessions SET metadata = json_set(COALESCE(metadata, '{}'), '$.subagent_tokens_checked', ?)
WHERE id = ? AND json_valid(COALESCE(metadata, '{}'))`
).run(fingerprint, s.session_id);
} catch {
/* the next boot simply checks this session again */
}
}
return { sessions: scanned, stamped };
}
+17 -1
View File
@@ -14,7 +14,9 @@
* ones kept there are what the dormant stage/detection code is tested against.
* `AGENTHUB_SETTINGS_FILE` goes to the same temp dir, so a suite that saves a
* setting never writes the developer's real `~/.config/agenthub/settings.env`,
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network.
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network. The
* `GIT_DIR`-family variables a git hook exports are dropped too, so a test's
* own `git init`/`commit` can never land in the real repository.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -44,6 +46,20 @@ const env = {
};
delete env.TMUX;
delete env.TMUX_PANE;
// Git exports these to a hook it runs from a linked worktree (the pre-commit
// hook runs this suite): inherited, every `git init`/`commit` a test makes in
// its temp folder went to the REAL repository instead — commits on the
// worktree's HEAD and `core.bare = true` in the shared config.
for (const k of [
"GIT_DIR",
"GIT_INDEX_FILE",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_PREFIX",
"GIT_OBJECT_DIRECTORY",
]) {
delete env[k];
}
const result = spawnSync(process.execPath, ["--test", ...args, ...(args.length ? [] : files)], {
stdio: "inherit",
+1
View File
@@ -760,6 +760,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory, `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+3 -1
View File
@@ -1813,11 +1813,13 @@ describe("Database Integrity", () => {
assert.ok(indexes.includes("idx_agents_session"));
assert.ok(indexes.includes("idx_agents_status"));
// (session_id, id) is NOT a prefix of idx_events_session_type: the hook
// (session_id, id) is NOT a prefix of idx_events_session_type_tool: the hook
// duplicate guard reads a session's latest event by id through it.
assert.ok(indexes.includes("idx_events_session_created"));
assert.ok(indexes.includes("idx_events_session"));
assert.ok(indexes.includes("idx_events_type"));
assert.ok(indexes.includes("idx_events_session_type_tool"));
assert.ok(!indexes.includes("idx_events_session_type"));
assert.ok(indexes.includes("idx_events_created"));
assert.ok(indexes.includes("idx_sessions_status"));
assert.ok(indexes.includes("idx_sessions_started"));
+60
View File
@@ -384,3 +384,63 @@ describe("cc-profiles", () => {
assert.throws(() => profiles.listProfiles(), { code: "EBADJSON" });
});
});
// The state a running session can leave behind: `.claude.json` still names one
// account while the credentials store holds ANOTHER account's token. The token
// is what Claude Code actually uses, so its owner (asked of Anthropic by the
// route, passed in here) decides who is logged in.
describe("cc-profiles — identity and token disagree", () => {
/** Thắng and Victor saved; then Victor's token lands under Thắng's identity. */
function mixedLogin() {
login("uuid-t", "t@example.com");
profiles.saveProfile("thang");
login("uuid-v", "v@example.com");
profiles.saveProfile("victor");
login("uuid-t", "t@example.com");
fs.writeFileSync(
CREDS,
JSON.stringify({
claudeAiOauth: { accessToken: "at-v-new", refreshToken: "rt-v-new", expiresAt: 222 },
})
);
return { uuid: "uuid-v", email: "v@example.com" };
}
const stored = (name) => profiles.readProfile(name);
it("marks the token's owner active and reports the disagreement", () => {
const owner = mixedLogin();
const { items, active, mismatch } = profiles.listProfiles({ owner });
assert.deepEqual(
items.filter((p) => p.active).map((p) => p.name),
["victor"]
);
assert.equal(active.email, "v@example.com");
assert.equal(mismatch.configEmail, "t@example.com");
// Without an owner (offline) the config is all there is, as before.
assert.equal(profiles.listProfiles().mismatch, null);
});
it("a switch saves the outgoing token into its OWNER's profile, never the one config names", () => {
const owner = mixedLogin();
profiles.activateProfile("thang", { owner });
assert.equal(stored("victor").credentials.refreshToken, "rt-v-new");
assert.equal(stored("victor").account.emailAddress, "v@example.com");
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
// And the switch itself landed: Thắng's token and identity, together.
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-t");
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-t");
});
it("switching to the owner's own profile keeps the live (newest) token and fixes the identity", () => {
const owner = mixedLogin();
profiles.activateProfile("victor", { owner });
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.refreshToken, "rt-v-new");
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-v");
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
});
it("refuses to save a profile while they disagree", () => {
const owner = mixedLogin();
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
});
});
+9
View File
@@ -265,3 +265,12 @@ describe("profileUsage", () => {
);
});
});
describe("liveOwner", () => {
// The profiles routes call it; v2.5.9 defined it but never exported it, so
// GET /api/cc-config/profiles answered "ccUsage.liveOwner is not a function".
it("is exported, and is null with no live login", async () => {
assert.equal(typeof usage.liveOwner, "function");
assert.equal(await usage.liveOwner(), null);
});
});

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