Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a54978eef | |||
| ca821008e2 |
+9
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BellRing } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||
import { workspaceHref } from "./SessionRowMenu";
|
||||
import type { Session } from "../lib/types";
|
||||
|
||||
@@ -32,6 +33,9 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||
}, []);
|
||||
// The live panes (for the links) move only when a lane's run columns do.
|
||||
const loadRuns = useCallback(() => {
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
@@ -40,11 +44,14 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
loadRuns();
|
||||
}, [load, loadRuns]);
|
||||
useLiveRefresh(load, {
|
||||
intervalMs: 15000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
|
||||
useLiveRefresh(loadRuns, { intervalMs: 15000, match: runChanged });
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,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,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
interface SagiAvatarProps {
|
||||
@@ -76,10 +76,21 @@ if (typeof window !== "undefined") {
|
||||
});
|
||||
}
|
||||
|
||||
export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps) {
|
||||
// The pupil offset is written as CSS variables on the root, never as state, so
|
||||
// following the cursor costs no React render.
|
||||
function setPupil(el: SVGSVGElement | null, x: number, y: number) {
|
||||
el?.style.setProperty("--sagi-px", `${x}px`);
|
||||
el?.style.setProperty("--sagi-py", `${y}px`);
|
||||
}
|
||||
|
||||
// memo: Sagi re-renders on every brain tick; the avatar only on mood/size/motion.
|
||||
export const SagiAvatar = memo(function SagiAvatar({
|
||||
mood,
|
||||
reducedMotion,
|
||||
size = 68,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const [pupil, setPupil] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
@@ -89,7 +100,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tracking) {
|
||||
setPupil({ x: 0, y: 0 });
|
||||
setPupil(rootRef.current, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +116,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
// Normalize then clamp to the eye socket range.
|
||||
const nx = (dx / dist) * Math.min(1, dist / 240);
|
||||
const ny = (dy / dist) * Math.min(1, dist / 240);
|
||||
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
|
||||
setPupil(el, nx * MAX_PUPIL_SHIFT, ny * MAX_PUPIL_SHIFT);
|
||||
};
|
||||
|
||||
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
||||
@@ -202,7 +213,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-left">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -226,7 +237,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-right">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -792,4 +803,4 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file SagiMount.tsx
|
||||
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||
* WebSocket reducer.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||
|
||||
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||
export function useSagiEnabled(): boolean {
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function SagiMount() {
|
||||
if (!useSagiEnabled()) return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Sagi />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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;
|
||||
@@ -265,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 (
|
||||
@@ -282,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}
|
||||
@@ -314,7 +315,7 @@ function QuotaChips() {
|
||||
tools={tools}
|
||||
onEnter={show}
|
||||
onLeave={hide}
|
||||
onRefresh={() => load(true)}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -153,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. */
|
||||
@@ -164,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(" ");
|
||||
}
|
||||
@@ -309,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);
|
||||
@@ -320,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]);
|
||||
@@ -337,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(() => {
|
||||
@@ -394,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")}
|
||||
|
||||
@@ -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: [
|
||||
@@ -195,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");
|
||||
|
||||
@@ -204,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
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,9 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
||||
if (!enabled) return;
|
||||
const live = throttle(() => loadRef.current(), throttleMs);
|
||||
const visible = () => document.visibilityState === "visible";
|
||||
// A hidden tab ignores events: onVis reloads once when it is shown again.
|
||||
const offMsg = eventBus.subscribe((msg) => {
|
||||
if (matchRef.current?.(msg)) live();
|
||||
if (visible() && matchRef.current?.(msg)) live();
|
||||
});
|
||||
let wasDown = !eventBus.connected;
|
||||
// (Guarded: test doubles of the bus often stub `subscribe` only.)
|
||||
@@ -47,7 +48,7 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
||||
if (!up) wasDown = true;
|
||||
else if (wasDown) {
|
||||
wasDown = false;
|
||||
live();
|
||||
if (visible()) live();
|
||||
}
|
||||
})
|
||||
: () => undefined;
|
||||
|
||||
@@ -121,9 +121,11 @@ export function useNotifications() {
|
||||
type: NotifType,
|
||||
title: string,
|
||||
body: string,
|
||||
url?: Promise<string | undefined>
|
||||
// A thunk: the pane lookup (GET /api/run) runs only for a
|
||||
// notification that is actually shown, never per incoming event.
|
||||
url?: () => Promise<string | undefined>
|
||||
) =>
|
||||
void Promise.resolve(url)
|
||||
void Promise.resolve(url?.())
|
||||
.then((u) => notify(title, body, u))
|
||||
.then((r) => {
|
||||
if (r.via === "native" || r.via === "push" || r.via === "local")
|
||||
@@ -138,7 +140,7 @@ export function useNotifications() {
|
||||
"onNewSession",
|
||||
i18n.t("errors:notifications.newSession"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
() => laneUrlForSession(s.id)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export function useNotifications() {
|
||||
"onSessionError",
|
||||
i18n.t("errors:notifications.sessionError"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
() => laneUrlForSession(s.id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -158,10 +160,7 @@ export function useNotifications() {
|
||||
if (!prefs.onSubagentSpawn) return;
|
||||
const a = msg.data as Agent;
|
||||
if (a.type === "subagent") {
|
||||
show(
|
||||
"onSubagentSpawn",
|
||||
i18n.t("errors:notifications.subagentSpawned"),
|
||||
a.name,
|
||||
show("onSubagentSpawn", i18n.t("errors:notifications.subagentSpawned"), a.name, () =>
|
||||
laneUrlForSession(a.session_id)
|
||||
);
|
||||
}
|
||||
@@ -169,7 +168,7 @@ export function useNotifications() {
|
||||
}
|
||||
case "new_event": {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
const url = laneUrlForSession(ev.session_id);
|
||||
const url = () => laneUrlForSession(ev.session_id);
|
||||
// The two states where Claude is BLOCKED on the user get their own
|
||||
// flags (issue #29): `Notification` is a permission prompt or a
|
||||
// question, `Stop` is the end of a turn. Both used to hang off
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file useSpin.ts
|
||||
* @description A Refresh button's busy state. `run` calls the reload with no
|
||||
* arguments (an `onClick={load}` handed the click event to it) and keeps
|
||||
* `spinning` true for at least `minMs`, so a reload answered from a cache or
|
||||
* a throttle in a few milliseconds still shows the user that it ran.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* @returns `[spinning, run]`. A click while the reload is still in flight is
|
||||
* dropped; once it has answered, a click reloads again even if the icon is
|
||||
* still finishing its minimum spin.
|
||||
*/
|
||||
export function useSpin(fn: () => unknown, minMs = 600): [boolean, () => Promise<void>] {
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const inFlight = useRef(false);
|
||||
const lastRun = useRef(0);
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
const alive = useRef(true);
|
||||
// Set on mount too: StrictMode mounts, unmounts and mounts again, and a
|
||||
// cleanup-only effect left `alive` false — the spin never stopped and the
|
||||
// button stayed busy for good.
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const id = ++lastRun.current;
|
||||
const started = Date.now();
|
||||
setSpinning(true);
|
||||
try {
|
||||
await fnRef.current();
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
const rest = minMs - (Date.now() - started);
|
||||
if (rest > 0) await new Promise((r) => setTimeout(r, rest));
|
||||
// Only the newest run stops the spin: an older one's floor ending must
|
||||
// not cut short a reload clicked after it.
|
||||
if (alive.current && id === lastRun.current) setSpinning(false);
|
||||
}
|
||||
}, [minMs]);
|
||||
|
||||
return [spinning, run];
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export type ThemeMode = Theme | "system";
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
|
||||
light: ["default", "apple-light", "canvas", "orca-light"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
const MODE_KEY = "theme";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file useThrottledValue.ts
|
||||
* @description Returns `value`, but lets it change at most once per `ms`
|
||||
* (leading + trailing, via `lib/throttle.ts`): the first change shows at once,
|
||||
* a burst collapses into one more update when it settles, and the last value
|
||||
* always lands. For effect keys that move on every hook (a lane's status or
|
||||
* session flipping between two sessions) so the fetch they drive cannot storm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { throttle } from "../lib/throttle";
|
||||
|
||||
export function useThrottledValue<T>(value: T, ms: number): T {
|
||||
const [shown, setShown] = useState(value);
|
||||
const latest = useRef(value);
|
||||
latest.current = value;
|
||||
const push = useMemo(() => throttle(() => setShown(latest.current), ms), [ms]);
|
||||
useEffect(() => () => push.cancel(), [push]);
|
||||
useEffect(() => push(), [value, push]);
|
||||
return shown;
|
||||
}
|
||||
@@ -128,7 +128,10 @@ export function useWebSocket(onMessage: MessageHandler) {
|
||||
// restart is picked up within a few seconds rather than after a long
|
||||
// idle wait. The focus/online/visibility listeners below reconnect
|
||||
// instantly on top of this for the common "user comes back" case.
|
||||
const delay = Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000);
|
||||
// Jittered (×0.5–1.5) so a server restart is not met by every
|
||||
// browser reconnecting in the same instant.
|
||||
const delay =
|
||||
Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000) * (0.5 + Math.random());
|
||||
reconnectAttempts.current++;
|
||||
reconnectTimer.current = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
+32
-88
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description i18next bootstrap for the dashboard client. Registers bundled JSON
|
||||
* locale files for English (`en`) and Vietnamese (`vi`), wires browser language detection, and exports the configured
|
||||
* singleton consumed by `react-i18next`.
|
||||
* @description i18next bootstrap for the dashboard client. Loads the JSON locale
|
||||
* files for English (`en`) and Vietnamese (`vi`) lazily — only the active language
|
||||
* (and the `en` fallback) — wires browser language detection, and exports the
|
||||
* configured singleton consumed by `react-i18next` plus `i18nReady`, the promise
|
||||
* the entry point awaits before the first render.
|
||||
*
|
||||
* ## Namespaces
|
||||
* Translations are split by feature area (`dashboard`, `sessions`, `settings`,
|
||||
@@ -38,14 +40,7 @@
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./locales/en/common.json`
|
||||
* - `./locales/vi/common.json`
|
||||
* - `./locales/en/nav.json`
|
||||
* - `./locales/vi/nav.json`
|
||||
* - `./locales/en/dashboard.json`
|
||||
* - `./locales/vi/dashboard.json`
|
||||
* - `./locales/en/sessions.json`
|
||||
* - `./locales/vi/sessions.json`
|
||||
* - `./locales/<lng>/<ns>.json` (lazy, via `import.meta.glob`)
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
@@ -57,44 +52,30 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import i18n from "i18next";
|
||||
import i18n, { type BackendModule, type ResourceKey } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import common_en from "./locales/en/common.json";
|
||||
import common_vi from "./locales/vi/common.json";
|
||||
import nav_en from "./locales/en/nav.json";
|
||||
import nav_vi from "./locales/vi/nav.json";
|
||||
import dashboard_en from "./locales/en/dashboard.json";
|
||||
import dashboard_vi from "./locales/vi/dashboard.json";
|
||||
import sessions_en from "./locales/en/sessions.json";
|
||||
import sessions_vi from "./locales/vi/sessions.json";
|
||||
import activity_en from "./locales/en/activity.json";
|
||||
import activity_vi from "./locales/vi/activity.json";
|
||||
import analytics_en from "./locales/en/analytics.json";
|
||||
import analytics_vi from "./locales/vi/analytics.json";
|
||||
import workflows_en from "./locales/en/workflows.json";
|
||||
import workflows_vi from "./locales/vi/workflows.json";
|
||||
import settings_en from "./locales/en/settings.json";
|
||||
import settings_vi from "./locales/vi/settings.json";
|
||||
import kanban_en from "./locales/en/kanban.json";
|
||||
import kanban_vi from "./locales/vi/kanban.json";
|
||||
import errors_en from "./locales/en/errors.json";
|
||||
import errors_vi from "./locales/vi/errors.json";
|
||||
import updates_en from "./locales/en/updates.json";
|
||||
import updates_vi from "./locales/vi/updates.json";
|
||||
import ccConfig_en from "./locales/en/ccConfig.json";
|
||||
import ccConfig_vi from "./locales/vi/ccConfig.json";
|
||||
import codexConfig_en from "./locales/en/codexConfig.json";
|
||||
import codexConfig_vi from "./locales/vi/codexConfig.json";
|
||||
import run_en from "./locales/en/run.json";
|
||||
import run_vi from "./locales/vi/run.json";
|
||||
import alerts_en from "./locales/en/alerts.json";
|
||||
import alerts_vi from "./locales/vi/alerts.json";
|
||||
import splash_en from "./locales/en/splash.json";
|
||||
import splash_vi from "./locales/vi/splash.json";
|
||||
import lanes_en from "./locales/en/lanes.json";
|
||||
import lanes_vi from "./locales/vi/lanes.json";
|
||||
// Every locale file is its own lazy chunk (vite.config.ts groups them into one
|
||||
// chunk per language), so a page loads only the language it shows. i18next
|
||||
// reads them through this inline backend: `init` fetches the detected language
|
||||
// (plus the `en` fallback), and `changeLanguage` fetches a new one BEFORE it
|
||||
// switches, so no raw key is ever painted.
|
||||
const localeFiles = import.meta.glob<Record<string, unknown>>("./locales/*/*.json", {
|
||||
import: "default",
|
||||
});
|
||||
const lazyLocales: BackendModule = {
|
||||
type: "backend",
|
||||
init() {},
|
||||
read(lng, ns, cb) {
|
||||
const load = localeFiles[`./locales/${lng}/${ns}.json`];
|
||||
if (!load) return cb(null, {});
|
||||
load().then(
|
||||
(data) => cb(null, data as ResourceKey),
|
||||
(err) => cb(err, false)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Keep `<html lang>` on the active language, so a screen reader speaks the
|
||||
* page with the right voice and the browser hyphenates/translates correctly.
|
||||
@@ -105,50 +86,13 @@ export function syncDocumentLang(): void {
|
||||
}
|
||||
i18n.on("languageChanged", syncDocumentLang);
|
||||
|
||||
i18n
|
||||
/** Resolves once the detected language's namespaces are loaded; `main.tsx`
|
||||
* awaits it before the first render. */
|
||||
export const i18nReady = i18n
|
||||
.use(lazyLocales)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: {
|
||||
common: common_en,
|
||||
nav: nav_en,
|
||||
dashboard: dashboard_en,
|
||||
sessions: sessions_en,
|
||||
activity: activity_en,
|
||||
analytics: analytics_en,
|
||||
workflows: workflows_en,
|
||||
settings: settings_en,
|
||||
kanban: kanban_en,
|
||||
errors: errors_en,
|
||||
updates: updates_en,
|
||||
ccConfig: ccConfig_en,
|
||||
codexConfig: codexConfig_en,
|
||||
run: run_en,
|
||||
alerts: alerts_en,
|
||||
splash: splash_en,
|
||||
lanes: lanes_en,
|
||||
},
|
||||
vi: {
|
||||
common: common_vi,
|
||||
nav: nav_vi,
|
||||
dashboard: dashboard_vi,
|
||||
sessions: sessions_vi,
|
||||
activity: activity_vi,
|
||||
analytics: analytics_vi,
|
||||
workflows: workflows_vi,
|
||||
settings: settings_vi,
|
||||
kanban: kanban_vi,
|
||||
errors: errors_vi,
|
||||
updates: updates_vi,
|
||||
ccConfig: ccConfig_vi,
|
||||
codexConfig: codexConfig_vi,
|
||||
run: run_vi,
|
||||
alerts: alerts_vi,
|
||||
splash: splash_vi,
|
||||
lanes: lanes_vi,
|
||||
},
|
||||
},
|
||||
supportedLngs: ["en", "vi"],
|
||||
nonExplicitSupportedLngs: true,
|
||||
fallbackLng: "en",
|
||||
@@ -182,5 +126,5 @@ i18n
|
||||
|
||||
syncDocumentLang();
|
||||
|
||||
/** Configured i18next instance — import side effects run {@link init}. */
|
||||
/** Configured i18next instance — import side effects start {@link init}. */
|
||||
export default i18n;
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+80
-1
@@ -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
|
||||
@@ -2298,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) }),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.9 (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"
|
||||
>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+21
-2
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
@@ -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`:
|
||||
@@ -2427,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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.5.9
|
||||
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:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.9",
|
||||
"version": "2.5.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.5.9",
|
||||
"version": "2.5.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.9",
|
||||
"version": "2.5.10",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @file claude-pool.test.js
|
||||
* @description The Claude account pool (server/lib/claude-pool.js): token
|
||||
* validation and file modes, the balancer's order (full quota last, then fewest
|
||||
* live panes, then least recent spend, expired accounts never), and spawnRun
|
||||
* handing the pick to a Claude pane through tmux `-e CLAUDE_CODE_OAUTH_TOKEN`
|
||||
* and recording it — but not for a Codex pane, and not with an empty pool.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "claude-pool-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "claude");
|
||||
|
||||
const { db } = require("../db");
|
||||
const ccUsage = require("../lib/cc-usage");
|
||||
const pool = require("../lib/claude-pool");
|
||||
const tmux = require("../lib/tmux");
|
||||
const pty = require("../lib/pty-run");
|
||||
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
const realFetchUsage = ccUsage.fetchUsage;
|
||||
|
||||
function run(id, account, { status = "completed", session = null, started } = {}) {
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
|
||||
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
|
||||
).run(id, session, status, started || new Date().toISOString(), account);
|
||||
}
|
||||
|
||||
function spend(session, tokens) {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
|
||||
).run(session, session);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(session, new Date().toISOString().slice(0, 13), tokens);
|
||||
}
|
||||
|
||||
before(() => {
|
||||
// No network: every quota read answers "no reading" unless a test says otherwise.
|
||||
ccUsage.fetchUsage = async () => {
|
||||
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
||||
};
|
||||
});
|
||||
|
||||
after(() => {
|
||||
ccUsage.fetchUsage = realFetchUsage;
|
||||
tmux.__setExecImpl(null);
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("claude pool", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||
pool.__quotaCache.clear();
|
||||
});
|
||||
|
||||
it("is empty until an admin adds an account, and stores it 0600", () => {
|
||||
assert.equal(pool.pickAccount(), null);
|
||||
assert.throws(() => pool.addAccount("a", "not-a-token"), { code: "EBADTOKEN" });
|
||||
assert.throws(() => pool.addAccount("../x", TOKEN("a")), { code: "EBADNAME" });
|
||||
const added = pool.addAccount("a", TOKEN("a"));
|
||||
assert.ok(Date.parse(added.expiresAt) > Date.now() + 360 * 864e5);
|
||||
assert.throws(() => pool.addAccount("a", TOKEN("b")), { code: "EEXISTS" });
|
||||
const mode = fs.statSync(path.join(pool.poolDir(), "a.json")).mode & 0o777;
|
||||
assert.equal(mode, 0o600);
|
||||
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
|
||||
assert.ok(
|
||||
!JSON.stringify(pool.poolStatus()).includes(TOKEN("a")),
|
||||
"status never shows a token"
|
||||
);
|
||||
pool.removeAccount("a");
|
||||
assert.equal(pool.pickAccount(), null);
|
||||
});
|
||||
|
||||
it("prefers fewer live panes, then less recent spend, and skips expired tokens", () => {
|
||||
for (const n of ["a", "b", "c"]) pool.addAccount(n, TOKEN(n));
|
||||
assert.equal(pool.pickAccount().name, "a");
|
||||
|
||||
run("r1", "a", { status: "running" });
|
||||
assert.equal(pool.pickAccount().name, "b");
|
||||
|
||||
run("r2", "b", { session: "s-b" });
|
||||
spend("s-b", 100000);
|
||||
const picked = pool.pickAccount();
|
||||
assert.equal(picked.name, "c");
|
||||
assert.equal(picked.token, TOKEN("c"));
|
||||
|
||||
const status = Object.fromEntries(pool.poolStatus().map((a) => [a.name, a]));
|
||||
assert.equal(status.a.livePanes, 1);
|
||||
assert.ok(status.b.spend5h > 0 && status.b.spend7d >= status.b.spend5h);
|
||||
assert.equal(status.c.spend5h, 0);
|
||||
|
||||
// c's token is a year old: never handed out.
|
||||
const file = path.join(pool.poolDir(), "c.json");
|
||||
const rec = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
rec.addedAt = new Date(Date.now() - 366 * 864e5).toISOString();
|
||||
fs.writeFileSync(file, JSON.stringify(rec));
|
||||
assert.equal(pool.pickAccount().name, "b");
|
||||
});
|
||||
|
||||
it("puts an account whose quota is nearly used up last", async () => {
|
||||
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
|
||||
ccUsage.fetchUsage = async (token) => ({
|
||||
fiveHour: { utilization: token === TOKEN("a") ? 95 : 10, resetsAt: null },
|
||||
sevenDay: { utilization: 20, resetsAt: null },
|
||||
});
|
||||
try {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
run("r1", "b", { status: "running" }); // b busier, but a is full
|
||||
assert.equal(pool.pickAccount().name, "b");
|
||||
assert.equal(pool.poolStatus().find((x) => x.name === "a").full, true);
|
||||
} finally {
|
||||
ccUsage.fetchUsage = async () => {
|
||||
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a Claude pane the picked account's token and records it", () => {
|
||||
pool.addAccount("a", TOKEN("a"));
|
||||
let argv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") argv = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "claude" });
|
||||
assert.ok(argv.includes(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`));
|
||||
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`) < argv.indexOf("--"));
|
||||
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||
assert.equal(row.account, "a");
|
||||
|
||||
argv = null;
|
||||
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "codex" });
|
||||
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
|
||||
|
||||
pool.removeAccount("a");
|
||||
argv = null;
|
||||
pty.spawnRun({ laneId: 9, cwd: "/tmp", tool: "claude" });
|
||||
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Tests the DASHBOARD_CLIENT_DIST override in server/index.js. A plugin
|
||||
* install runs the server from a read-only plugin cache directory, so the
|
||||
* client bundle is served from the writable runtime dir instead of the
|
||||
* checkout's client/dist. Also asserts the API still answers when the
|
||||
* checkout's client/dist, with a hashed asset's pre-compressed `.br`/`.gz`
|
||||
* sibling sent to a browser that accepts it. Also asserts the API still answers when the
|
||||
* configured bundle directory does not exist yet (the normal state before
|
||||
* /agenthub-open builds it).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,6 +20,16 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-clientdist-"));
|
||||
const DIST = path.join(TMP, "client-dist");
|
||||
fs.mkdirSync(DIST);
|
||||
fs.writeFileSync(path.join(DIST, "index.html"), "<!doctype html><title>from-runtime</title>");
|
||||
// A hashed bundle with the pre-compressed siblings the client build writes.
|
||||
fs.mkdirSync(path.join(DIST, "assets"));
|
||||
const BUNDLE = "console.log('plain bundle');".repeat(50);
|
||||
fs.writeFileSync(path.join(DIST, "assets", "app-abc123.js"), BUNDLE);
|
||||
fs.writeFileSync(
|
||||
path.join(DIST, "assets", "app-abc123.js.br"),
|
||||
require("zlib").brotliCompressSync(BUNDLE)
|
||||
);
|
||||
fs.writeFileSync(path.join(DIST, "assets", "app-abc123.js.gz"), require("zlib").gzipSync(BUNDLE));
|
||||
fs.writeFileSync(path.join(DIST, "assets", "only-plain.css"), "body{}");
|
||||
|
||||
// Must be set BEFORE requiring the server: the data dir, discovery file and
|
||||
// the static mount are all resolved at startup. NODE_ENV=production is the
|
||||
@@ -38,12 +49,15 @@ const { db } = require("../db");
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
function get(urlPath) {
|
||||
function get(urlPath, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(new URL(urlPath, BASE), (res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c));
|
||||
res.on("end", () => resolve({ status: res.statusCode, body }));
|
||||
const req = http.get(new URL(urlPath, BASE), { headers }, (res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
const raw = Buffer.concat(chunks);
|
||||
resolve({ status: res.statusCode, body: raw.toString(), raw, headers: res.headers });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
@@ -68,6 +82,33 @@ describe("DASHBOARD_CLIENT_DIST override", () => {
|
||||
assert.match(res.body, /from-runtime/);
|
||||
});
|
||||
|
||||
it("serves a hashed asset's pre-compressed sibling to a browser that takes it", async () => {
|
||||
const zlib = require("zlib");
|
||||
const br = await get("/assets/app-abc123.js", { "accept-encoding": "gzip, deflate, br" });
|
||||
assert.equal(br.status, 200);
|
||||
assert.equal(br.headers["content-encoding"], "br");
|
||||
assert.match(br.headers["content-type"], /javascript/);
|
||||
assert.match(br.headers.vary, /Accept-Encoding/i);
|
||||
assert.match(br.headers["cache-control"], /immutable/);
|
||||
assert.equal(zlib.brotliDecompressSync(br.raw).toString(), BUNDLE);
|
||||
|
||||
const gz = await get("/assets/app-abc123.js", { "accept-encoding": "gzip" });
|
||||
assert.equal(gz.headers["content-encoding"], "gzip");
|
||||
assert.equal(zlib.gunzipSync(gz.raw).toString(), BUNDLE);
|
||||
|
||||
const plain = await get("/assets/app-abc123.js", { "accept-encoding": "identity" });
|
||||
assert.equal(plain.headers["content-encoding"], undefined);
|
||||
assert.equal(plain.body, BUNDLE);
|
||||
|
||||
// No sibling on disk: the plain file, as before.
|
||||
const css = await get("/assets/only-plain.css", { "accept-encoding": "br" });
|
||||
assert.equal(css.headers["content-encoding"], undefined);
|
||||
assert.equal(css.body, "body{}");
|
||||
// A name that is not a flat file name never reaches the disk lookup.
|
||||
const walk = await get("/assets/..%2findex.html", { "accept-encoding": "br" });
|
||||
assert.equal(walk.headers["content-encoding"], undefined);
|
||||
});
|
||||
|
||||
it("keeps the API working alongside the override", async () => {
|
||||
const res = await get("/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
@@ -1319,6 +1319,22 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
assert.equal(after.status, "idle");
|
||||
});
|
||||
|
||||
it("healAllRunIds judges by the live-session set it is handed", async () => {
|
||||
// The server's beat lists tmux once and passes the set, instead of one
|
||||
// synchronous has-session per lane.
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { healAllRunIds } = require("../routes/lanes");
|
||||
const lane = await adoptedLane("heal-all-live-set");
|
||||
const runId = `agenthub-lane-${lane.id}`;
|
||||
lanesLib.updateLane(lane.id, { run_id: runId, status: "running" });
|
||||
|
||||
healAllRunIds(new Set([runId]));
|
||||
assert.equal(lanesLib.getLane(lane.id).run_id, runId);
|
||||
|
||||
healAllRunIds(new Set());
|
||||
assert.equal(lanesLib.getLane(lane.id).run_id, null);
|
||||
});
|
||||
|
||||
it("starts a session on the AI tool the request names, not the workspace's", async () => {
|
||||
const lane = await adoptedLane("session-tool");
|
||||
assert.equal(lane.tool, "claude");
|
||||
|
||||
@@ -541,6 +541,30 @@ describe("hook → lane binding", () => {
|
||||
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
|
||||
});
|
||||
|
||||
it("keeps the lane's session when a split pane (slot 1–9) of the workspace sends hooks", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-split-pane" });
|
||||
const id = created.body.lane.id;
|
||||
const hook = (session_id, run_id) =>
|
||||
request("POST", "/api/hooks/event", {
|
||||
hook_type: "PostToolUse",
|
||||
run_id,
|
||||
data: {
|
||||
session_id,
|
||||
cwd: "/tmp/lane-split-pane",
|
||||
tool_name: "Read",
|
||||
tool_use_id: session_id,
|
||||
},
|
||||
});
|
||||
await hook("sess-main", `agenthub-lane-${id}`);
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-main");
|
||||
// A split pane's session never takes the lane over…
|
||||
await hook("sess-split", `agenthub-lane-${id}-2`);
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-main");
|
||||
// …while a session outside the dashboard still does, as before.
|
||||
await hook("sess-outside", undefined);
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-outside");
|
||||
});
|
||||
|
||||
it("clears needs_action only from the session that raised it, not from cross-session rebinds", async () => {
|
||||
const created = await request("POST", "/api/lanes", {
|
||||
cwd: "/tmp/lane-cross-session",
|
||||
|
||||
@@ -68,7 +68,13 @@ describe("pty-attach", () => {
|
||||
return "";
|
||||
});
|
||||
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||
assert.deepEqual(calls[0], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
|
||||
assert.deepEqual(calls[0].slice(0, 5), [
|
||||
"set-option",
|
||||
"-t",
|
||||
"=agenthub-lane-1:",
|
||||
"mouse",
|
||||
"on",
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches with the RUNNING tmux server's own binary, not the one on PATH", () => {
|
||||
|
||||
@@ -442,6 +442,22 @@ describe("pty-run", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("listRuns asks tmux once, not once more per listed session", () => {
|
||||
// Every tmux call is synchronous; the Workspace polls this every 5 s.
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args[0]);
|
||||
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
|
||||
return "";
|
||||
});
|
||||
const items = pty.listRuns();
|
||||
assert.deepEqual(
|
||||
items.map((r) => r.status),
|
||||
["running", "running"]
|
||||
);
|
||||
assert.deepEqual(calls, ["list-sessions"]);
|
||||
});
|
||||
|
||||
it("laneIdFromRunId parses the numeric lane id back out", () => {
|
||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
|
||||
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user