feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
# commit-msg hook – runs Commitlint
npx --no-install commitlint --edit "$1"
+4 -3
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.5.12 (2026-10-08) — Quit AgentHub (#65: `agenthub stop`, stays stopped until a server starts), Add to Chat, Settings in three groups, status-area icon (`server/lib/tray.js`), JetBrains-style Changes panel (ticked `paths`/`hunks`, `ignore`, `/git/blame`, file history), plan + quota for every AI tool (`server/lib/quota.js`), Grok Build/Kimi Code in, Gemini CLI/Hermes/OpenClaw out. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. _Last synced: v2.5.13 (2026-10-08) — roles admin/analyst/developer (`usePermissions`), Chat UI per account (Settings → Appearance, `/api/me/prefs`, `PaneChat.tsx`), one shared terminal size per pane (`pty-attach.js`). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -71,11 +71,12 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
- **The status-area icon is its own process, and only the user hides or shows it.** `scripts/tray.js` (systray2) is started and kept alive by a production server (`startTrayWatch` in `server/lib/tray.js`), never in server mode; it stops and starts AgentHub only through the CLI's own `agenthub stop|start`, so a Quit from the icon is a Quit. It is on by default; `<data dir>/runtime/tray-off` (Settings → System, or the icon's Hide) is the user's, and nothing else may remove it. A server never kills the icon on restart or Quit — after a Quit it is the way back — and only signals a pid whose `tray.json` heartbeat is fresh. - **The status-area icon is its own process, and only the user hides or shows it.** `scripts/tray.js` (systray2) is started and kept alive by a production server (`startTrayWatch` in `server/lib/tray.js`), never in server mode; it stops and starts AgentHub only through the CLI's own `agenthub stop|start`, so a Quit from the icon is a Quit. It is on by default; `<data dir>/runtime/tray-off` (Settings → System, or the icon's Hide) is the user's, and nothing else may remove it. A server never kills the icon on restart or Quit — after a Quit it is the way back — and only signals a pid whose `tray.json` heartbeat is fresh.
- **On macOS the same rules bind a LaunchAgent.** `scripts/launchd-service.js` exports the systemd module's API (`service-manager.js` picks by platform — require THAT, never one of the two directly, or a Mac silently gets the systemd no-op): `KeepAlive` is `Restart=always`, `writeUserService` writes the plist and loads nothing, and the deferred hand-over is `bootout` + `bootstrap` (never `kickstart`, which keeps the old plist). Two differences are deliberate: `AbandonProcessGroup` is true so a restart does not take the lane panes' tmux server with it, and because a failed `bootstrap` leaves NOTHING running, the bootstrap falls back to its detached spawn when the restart fails and no server is live. Availability is `launchctl print gui/<uid>` — no GUI login (SSH only) means the detached spawn, as before. - **On macOS the same rules bind a LaunchAgent.** `scripts/launchd-service.js` exports the systemd module's API (`service-manager.js` picks by platform — require THAT, never one of the two directly, or a Mac silently gets the systemd no-op): `KeepAlive` is `Restart=always`, `writeUserService` writes the plist and loads nothing, and the deferred hand-over is `bootout` + `bootstrap` (never `kickstart`, which keeps the old plist). Two differences are deliberate: `AbandonProcessGroup` is true so a restart does not take the lane panes' tmux server with it, and because a failed `bootstrap` leaves NOTHING running, the bootstrap falls back to its detached spawn when the restart fails and no server is live. Availability is `launchctl print gui/<uid>` — no GUI login (SSH only) means the detached spawn, as before.
- **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps. - **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps.
- **Deleting retired cache copies is the ONE thing here that touches the plugin cache, and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the newest survivor as a rollback cushion (`KEEP_RETIRED_ROOTS`, 1 — a full copy of this repo is ~130 MB, so the cushion is one build deep, not two), and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here. - **Deleting retired cache copies is the ONE thing here that touches the plugin cache during normal operation (the other is Uninstall, below), and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the newest survivor as a rollback cushion (`KEEP_RETIRED_ROOTS`, 1 — a full copy of this repo is ~130 MB, so the cushion is one build deep, not two), and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here.
- **Uninstall removes only what is AgentHub's, and never the user's work.** `scripts/uninstall.js` (`agenthub uninstall --yes`, `POST /api/settings/uninstall` — `confirm: true`, refused in server mode, run as a detached worker in its own `systemd-run --user --scope`, so no unit stop can reap it) goes in a fixed order: panes → unit (before the server, or `Restart=always` revives it) → servers → icon → stacks → config entries → `claude plugin uninstall` → files → plugin cache → data dir. In a shared file it removes only entries it can prove are its own (the `isOurEntry`/bootstrap hooks, `isOurs` statusline, the Codex/agy `remove()`s, the `# Written by AgentHub` TOMLs, the launcher marker); it removes only the marketplace named `agenthub`, never a shared one; it deletes the plugin cache only once `installed_plugins.json` no longer records `agenthub@*`; and `isSafeDataDir` refuses a data dir that is or contains the home, `CLAUDE_HOME` or the checkout, or is not recognisably AgentHub's. The AI tools' conversations, lane worktrees and branches, and anything in a user's repo are never touched. Every module is required up front, because the cache it runs from is deleted mid-run.
- **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one.
- **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id. - **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id.
- **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that).
- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A member sees only their own rows: every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no member); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a member's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per member, and `named-lock.js` takes only a plain name (a lock name was a path). It is still NOT OS isolation: every pane runs as the host account until S3, so do not call it multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`; hooks stay loopback-only in server mode. - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is still NOT OS isolation: every pane runs as the host account until S3, so do not call it multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode.
- **A pool token reaches a pane only through tmux `-e`, and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add`). An empty pool must leave every spawn exactly as before. - **A pool token reaches a pane only through tmux `-e`, and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add`). An empty pool must leave every spawn exactly as before.
- Preserve existing behavior unless explicitly asked to change it. - Preserve existing behavior unless explicitly asked to change it.
- Prefer minimal, reversible diffs. - Prefer minimal, reversible diffs.
+1 -1
View File
@@ -18,7 +18,7 @@ result and `/agenthub-open` to build the UI and get the URL.
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
the server stores data through `node:sqlite`). Full behavior, including the the server stores data through `node:sqlite`). Full behavior, including the
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md). uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
Follow the steps below instead when you want a checkout — to develop the Follow the steps below instead when you want a checkout — to develop the
dashboard, run it from source, or stay on Node 20. dashboard, run it from source, or stay on Node 20.
+3
View File
@@ -50,6 +50,9 @@ Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và s
Cập nhật bản mới: `/agenthub:agenthub-update`. Cập nhật bản mới: `/agenthub:agenthub-update`.
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`. `agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
Gỡ hẳn khỏi máy: **Settings → Hệ thống → Gỡ cài đặt**, hoặc `agenthub uninstall`
(chỉ liệt kê) rồi `agenthub uninstall --yes` — xoá cả database của AgentHub; các cuộc
trò chuyện của Claude Code/Codex, worktree và repository của bạn được giữ lại.
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**. **Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
+57 -4
View File
@@ -1498,7 +1498,23 @@ async function cmdStop() {
); );
return; return;
} }
const { killed } = await post("/api/settings/quit"); const res = await post("/api/settings/quit", undefined, { allowError: true });
if (res?.status) {
// Server mode: a shell on the host carries no login, and Quit is an admin's.
if (res.data?.error?.code === "EAUTH" || res.data?.error?.code === "EADMIN") {
console.error(
`${c.red("✖ This dashboard runs in server mode: Quit needs an admin login.")}\n` +
" Log in as an admin and use Settings → System → Quit AgentHub, or stop its service:\n" +
` ${c.bold("sudo systemctl stop agenthub")} ${c.dim("# system unit (one Linux user per account)")}\n` +
` ${c.bold("systemctl --user stop agenthub")} ${c.dim("# per-user unit")}`
);
} else {
const msg = res.data?.error?.message || `HTTP ${res.status}`;
console.error(c.red(`✖ POST /api/settings/quit → ${msg}`));
}
process.exit(1);
}
const { killed } = res;
const deadline = Date.now() + 15_000; const deadline = Date.now() + 15_000;
while ((await serverIsUp()) && Date.now() < deadline) { while ((await serverIsUp()) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250)); await new Promise((r) => setTimeout(r, 250));
@@ -1512,6 +1528,36 @@ async function cmdStop() {
); );
} }
/**
* Uninstall AgentHub (scripts/uninstall.js): without `--yes` it only lists what
* would go. Needs no server — it is the thing being removed.
*/
function cmdUninstall(flags) {
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
const uninstall = require(path.join(REPO_ROOT, "scripts", "uninstall.js"));
if (flags.yes !== true) {
const steps = uninstall.plan();
if (!steps.length) return console.log(`${c.dim("○")} Nothing of AgentHub's is left here`);
console.log(c.bold("agenthub uninstall --yes would:"));
for (const s of steps) {
console.log(` ${c.red("✖")} ${s.label}`);
for (const i of s.items) console.log(c.dim(` ${i}`));
}
console.log(
c.dim(
" Kept: the AI tools' own conversations, workspace worktrees and branches, your repos."
)
);
console.error(c.red("Nothing was removed. Re-run with --yes: agenthub uninstall --yes"));
process.exitCode = 1;
return;
}
const r = uninstall.run({ onLine: (l) => console.log(l) });
console.log(c.dim(`Log: ${r.log}`));
if (!r.ok) process.exitCode = 1;
}
/** /**
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent * Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
* (macOS) so the dashboard survives reboots without anything (Claude Code * (macOS) so the dashboard survives reboots without anything (Claude Code
@@ -2785,6 +2831,11 @@ const COMMAND_GROUPS = [
"", "",
"Quit: close every workspace terminal and stop the server until `agenthub start`", "Quit: close every workspace terminal and stop the server until `agenthub start`",
], ],
[
"uninstall",
"[--yes]",
"Remove AgentHub from this machine, its database included (lists first without --yes)",
],
[ [
"service", "service",
"install|uninstall|status [--port N]", "install|uninstall|status [--port N]",
@@ -2798,7 +2849,7 @@ const COMMAND_GROUPS = [
[ [
"users", "users",
"list|add|passwd|role|disable|enable|assign|audit [name]", "list|add|passwd|role|disable|enable|assign|audit [name]",
"Server mode's accounts (add: --role admin|analyst|member)", "Server mode's accounts (add: --role admin|analyst|developer)",
], ],
[ [
"accounts", "accounts",
@@ -3940,7 +3991,7 @@ async function cmdUsers(sub, positional, flags) {
return; return;
case "add": { case "add": {
need(); need();
const role = typeof flags.role === "string" ? flags.role : "member"; const role = typeof flags.role === "string" ? flags.role : "developer";
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role); const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
console.log(`${c.green("✔")} ${user.username} added (${user.role})`); console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
return; return;
@@ -3984,7 +4035,7 @@ async function cmdUsers(sub, positional, flags) {
default: default:
console.error( console.error(
c.red( 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]" "✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
) )
); );
process.exitCode = 1; process.exitCode = 1;
@@ -4061,6 +4112,8 @@ async function runCommand(argv) {
return cmdStart(flags); return cmdStart(flags);
case "stop": case "stop":
return cmdStop(); return cmdStop();
case "uninstall":
return cmdUninstall(flags);
case "service": case "service":
return cmdService(positional[0], flags); return cmdService(positional[0], flags);
case "server": case "server":
+619 -548
View File
File diff suppressed because it is too large Load Diff
@@ -93,6 +93,7 @@ import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
import { useSpin } from "../hooks/useSpin"; import { useSpin } from "../hooks/useSpin";
import { usePermissions } from "./AuthGate";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types"; import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -284,7 +285,9 @@ export function AlertsNotifications() {
const { t } = useTranslation("alerts"); const { t } = useTranslation("alerts");
const { t: ts } = useTranslation("settings"); const { t: ts } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("rules"); // Rules and channels (webhooks) fire for every user's sessions: an admin's.
const { isAdmin } = usePermissions();
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
// Rules // Rules
const [rules, setRules] = useState<AlertRule[]>([]); const [rules, setRules] = useState<AlertRule[]>([]);
@@ -361,8 +364,8 @@ export function AlertsNotifications() {
}, [unackedOnly, alerts.length, t]); }, [unackedOnly, alerts.length, t]);
useEffect(() => { useEffect(() => {
loadRules(); if (isAdmin) loadRules();
}, [loadRules]); }, [loadRules, isAdmin]);
useEffect(() => { useEffect(() => {
loadAlerts(); loadAlerts();
@@ -504,7 +507,7 @@ export function AlertsNotifications() {
(form.rule_type !== "budget" || (form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null)); (parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [ const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{ {
key: "rules", key: "rules",
label: ts("alertsHub.tabRules"), label: ts("alertsHub.tabRules"),
@@ -519,6 +522,7 @@ export function AlertsNotifications() {
badge: unacked || undefined, badge: unacked || undefined,
}, },
]; ];
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -1086,7 +1090,7 @@ export function AlertsNotifications() {
)} )}
</p> </p>
</div> </div>
{!alert.acknowledged_at && ( {!alert.acknowledged_at && alert.can_ack !== false && (
<button <button
onClick={() => onAck(alert.id)} onClick={() => onAck(alert.id)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0" className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
+25 -1
View File
@@ -6,7 +6,8 @@
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until * 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` * someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the * (`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`. * previous user's data. `useAuth()` gives the sidebar the user and `logout`;
* `usePermissions()` says what that user's role may do, so pages hide the rest.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -28,6 +29,29 @@ const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout:
/** The current login; `{mode: "local"}` outside server mode (and in tests). */ /** The current login; `{mode: "local"}` outside server mode (and in tests). */
export const useAuth = () => useContext(AuthContext); export const useAuth = () => useContext(AuthContext);
/**
* What the logged-in user may do — the server's rules (server/lib/auth.js,
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
* only their own — the one that matters is an analyst looking at another
* user's.
*/
export function usePermissions() {
const { state } = useAuth();
const user = state.mode === "server" ? state.user : null;
const local = state.mode !== "server";
const isAdmin = local || user?.role === "admin";
return {
isAdmin,
seesAll: local || (!!user && user.role !== "developer"),
// A developer only ever sees their own rows, so everything shown is theirs.
canEditLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
};
}
/** The mode this browser last saw, so a local dashboard paints at once. */ /** The mode this browser last saw, so a local dashboard paints at once. */
const MODE_KEY = "agenthub.authMode"; const MODE_KEY = "agenthub.authMode";
const RETRY_MS = 2000; const RETRY_MS = 2000;
+49 -39
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs"; import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets"; import { usePets } from "./usePets";
import { usePermissions } from "../AuthGate";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
const ITEM = const ITEM =
@@ -50,6 +51,8 @@ export function PetMenu({
const bundleInput = useRef<HTMLInputElement>(null); const bundleInput = useRef<HTMLInputElement>(null);
const [choosing, setChoosing] = useState(false); const [choosing, setChoosing] = useState(false);
const { list, own, installed, busy, error, wear, install, add, remove } = usePets(); const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Adding or deleting a pet changes the whole install: an admin's.
const { isAdmin } = usePermissions();
// Closes on a press outside the menu, or Escape. // Closes on a press outside the menu, or Escape.
useEffect(() => { useEffect(() => {
@@ -133,6 +136,7 @@ export function PetMenu({
</button> </button>
{list?.featured.map((f) => { {list?.featured.map((f) => {
const have = installed.get(f.id); const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return ( return (
<button <button
key={f.id} key={f.id}
@@ -173,48 +177,54 @@ export function PetMenu({
<span className="min-w-0 truncate">{p.displayName}</span> <span className="min-w-0 truncate">{p.displayName}</span>
{mark(p.id)} {mark(p.id)}
</button> </button>
<button {isAdmin && (
type="button" <button
data-testid={`pet-delete-${p.id}`} type="button"
aria-label={t("sagi.pet.delete", { name: p.displayName })} data-testid={`pet-delete-${p.id}`}
title={t("sagi.pet.delete", { name: p.displayName })} aria-label={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null} title={t("sagi.pet.delete", { name: p.displayName })}
onClick={() => void remove(p)} disabled={busy !== null}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger" onClick={() => void remove(p)}
> className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
<Trash2 className="h-3.5 w-3.5" /> >
</button> <Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
))} ))}
<div className="my-1.5 border-t border-border" /> <div className="my-1.5 border-t border-border" />
<button {isAdmin && (
type="button" <>
role="menuitem" <button
disabled={busy !== null} type="button"
className={ITEM} role="menuitem"
onClick={() => imageInput.current?.click()} disabled={busy !== null}
> className={ITEM}
{busy === "image" ? ( onClick={() => imageInput.current?.click()}
<Loader2 className="h-4 w-4 animate-spin" /> >
) : ( {busy === "image" ? (
<Upload className="h-4 w-4" /> <Loader2 className="h-4 w-4 animate-spin" />
)} ) : (
{t("sagi.pet.upload")} <Upload className="h-4 w-4" />
</button> )}
<button {t("sagi.pet.upload")}
type="button" </button>
role="menuitem" <button
disabled={busy !== null} type="button"
className={ITEM} role="menuitem"
onClick={() => bundleInput.current?.click()} disabled={busy !== null}
> className={ITEM}
{busy === "bundle" ? ( onClick={() => bundleInput.current?.click()}
<Loader2 className="h-4 w-4 animate-spin" /> >
) : ( {busy === "bundle" ? (
<PackageOpen className="h-4 w-4" /> <Loader2 className="h-4 w-4 animate-spin" />
)} ) : (
{t("sagi.pet.import")} <PackageOpen className="h-4 w-4" />
</button> )}
{t("sagi.pet.import")}
</button>
</>
)}
<a <a
href="https://codex-pets.net/" href="https://codex-pets.net/"
target="_blank" target="_blank"
+23 -16
View File
@@ -74,7 +74,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useAuth } from "./AuthGate"; import { useAuth, usePermissions } from "./AuthGate";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree"; import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -192,6 +192,8 @@ export function Sidebar({
}: SidebarProps) { }: SidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const auth = useAuth(); const auth = useAuth();
// Checking for (and applying) an update acts on the host: an admin's.
const canUpdate = usePermissions().isAdmin;
const asideRef = useRef<HTMLElement>(null); const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open); const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose); useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -468,7 +470,9 @@ export function Sidebar({
{[ {[
...NAV_KEYS, ...NAV_KEYS,
// Server mode: the Users page, for an admin or analyst. // Server mode: the Users page, for an admin or analyst.
...(auth.state.mode === "server" && auth.state.user && auth.state.user.role !== "member" ...(auth.state.mode === "server" &&
auth.state.user &&
auth.state.user.role !== "developer"
? [{ to: "/users", icon: UsersRound, key: "nav:users" }] ? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
: []), : []),
].map(({ to, icon: Icon, key }) => { ].map(({ to, icon: Icon, key }) => {
@@ -591,7 +595,7 @@ export function Sidebar({
checking, checking,
available: updateAvailable, available: updateAvailable,
failed: checkError, failed: checkError,
onCheck: onCheckUpdates, onCheck: canUpdate ? onCheckUpdates : undefined,
}} }}
onResetStats={() => { onResetStats={() => {
eventCountRef.current = 0; eventCountRef.current = 0;
@@ -633,7 +637,8 @@ interface ConnectionStatusModalProps {
checking: boolean; checking: boolean;
available: boolean; available: boolean;
failed: boolean; failed: boolean;
onCheck: () => void; /** Absent for a user who may not update (server mode, not an admin). */
onCheck?: () => void;
}; };
onResetStats: () => void; onResetStats: () => void;
} }
@@ -775,18 +780,20 @@ function ConnectionStatusModal({
{update.title} {update.title}
</p> </p>
</div> </div>
<button {update.onCheck && (
type="button" <button
onClick={update.onCheck} type="button"
disabled={update.checking} onClick={update.onCheck}
className="btn-ghost shrink-0 text-xs disabled:opacity-60" disabled={update.checking}
> className="btn-ghost shrink-0 text-xs disabled:opacity-60"
<RefreshCw >
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`} <RefreshCw
aria-hidden className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
/> aria-hidden
{t("nav:checkForUpdates")} />
</button> {t("nav:checkForUpdates")}
</button>
)}
</div> </div>
</Section> </Section>
-19
View File
@@ -27,7 +27,6 @@
* ## Public surface * ## Public surface
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior. * - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior. * - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior. * - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
* *
* ## Testing pointers * ## Testing pointers
@@ -52,11 +51,6 @@
* the signature and return type as stable unless release notes say otherwise. * the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests. * When behavior changes, update the `@file` overview and relevant tests.
* *
* **TextSkeleton**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TableRowSkeleton** * **TableRowSkeleton**
* Part of this module's public contract. Downstream imports should treat * Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise. * the signature and return type as stable unless release notes say otherwise.
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />; return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
} }
/**
* Single-line text placeholder. Good for trend/sub labels.
*/
export function TextSkeleton({
className = "",
width = "w-16",
}: {
className?: string;
width?: string;
}) {
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
}
/** /**
* Full table-row skeleton for tabular pages. Caller passes the column count so * Full table-row skeleton for tabular pages. Caller passes the column count so
* the skeleton lines up under existing headers. * the skeleton lines up under existing headers.
+4
View File
@@ -88,6 +88,7 @@ import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog"; import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { usePermissions } from "./AuthGate";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */ /** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha"; const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
@@ -125,6 +126,7 @@ function loadDismissedSha(): string | null {
* @returns `null` when no update is available or this release was dismissed. * @returns `null` when no update is available or this release was dismissed.
*/ */
export function UpdateNotifier() { export function UpdateNotifier() {
const { isAdmin } = usePermissions();
const { t } = useTranslation("updates"); const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null); const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha); const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
@@ -190,7 +192,9 @@ export function UpdateNotifier() {
status?.latest_release || status?.latest_release ||
status?.remote_sha || status?.remote_sha ||
(installed ? `installed:${installed.root}` : null); (installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
const show = Boolean( const show = Boolean(
isAdmin &&
status?.update_available && status?.update_available &&
dismissKey && dismissKey &&
dismissedSha !== dismissKey && dismissedSha !== dismissKey &&
+3 -3
View File
@@ -5,7 +5,7 @@
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a * (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a * 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 * 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 list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a
* lane's status or session moves (at most every 3 s) and handed over by the * 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 * 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 * destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
@@ -28,7 +28,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react"; import { Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api"; import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types"; import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes"; import { useWorkLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
import { useThrottledValue } from "../hooks/useThrottledValue"; import { useThrottledValue } from "../hooks/useThrottledValue";
import { import {
@@ -70,7 +70,7 @@ export function WorkspaceTree() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const selected = selectedFrom(location.pathname, location.search); const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes(); const { lanes, refresh: refreshLanes } = useWorkLanes();
const prefs = useProjectTreePrefs(); const prefs = useProjectTreePrefs();
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]); const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -0,0 +1,60 @@
/**
* @file AlertsNotifications.roles.test.tsx
* @description Server mode, not an admin: alert rules and channels fire for
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
* Acknowledge only on alerts the server marks theirs (`can_ack`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const rulesList = vi.fn(async () => ({ rules: [] }));
const alert = (id: number, can_ack: boolean) => ({
id,
rule_id: "r1",
rule_name: `rule ${id}`,
rule_type: "error_rate",
session_id: null,
agent_id: null,
message: `boom ${id}`,
details: null,
triggered_at: "2026-10-08T00:00:00.000Z",
acknowledged_at: null,
can_ack,
});
vi.mock("../../lib/api", () => ({
api: {
alerts: {
rules: { list: () => rulesList() },
list: vi.fn(async () => ({
alerts: [alert(1, true), alert(2, false)],
total: 2,
unacked: 2,
})),
ack: vi.fn(),
ackAll: vi.fn(),
},
},
}));
vi.mock("../AuthGate", () => ({
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
}));
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications for a non-admin", () => {
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
expect(await screen.findByText("boom 2")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
expect(rulesList).not.toHaveBeenCalled();
});
});
@@ -2,25 +2,52 @@
* @file AuthGate.test.tsx * @file AuthGate.test.tsx
* @description The login gate: local mode renders the app untouched, server * @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, * mode shows the login page until a login succeeds, a wrong password says so,
* and a later 401 `EAUTH` brings the login page back. * and a later 401 `EAUTH` brings the login page back. `usePermissions` and
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
* analyst reads everything but works only in their own workspaces, a
* developer only ever sees (and so changes) their own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen } from "@testing-library/react"; import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import "../../i18n"; import "../../i18n";
const me = vi.fn(); const me = vi.fn();
const login = vi.fn(); const login = vi.fn();
const LANES = [
{ id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 },
];
vi.mock("../../lib/api", () => ({ vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required", AUTH_REQUIRED_EVENT: "agenthub:auth-required",
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } }, api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
},
})); }));
import { AuthGate } from "../AuthGate"; import { AuthGate, usePermissions } from "../AuthGate";
import { useWorkLanes } from "../../hooks/useLanes";
const user = { id: 1, username: "mia", role: "member" }; function Probe() {
const p = usePermissions();
const { lanes } = useWorkLanes();
return (
<p>
{[
`admin:${p.isAdmin}`,
`all:${p.seesAll}`,
`mine:${p.canEditLane({ owner_id: 5 })}`,
`theirs:${p.canEditLane({ owner_id: 6 })}`,
`work:${lanes.map((l) => l.id).join(",")}`,
].join(" ")}
</p>
);
}
const user = { id: 1, username: "mia", role: "developer" };
beforeEach(() => { beforeEach(() => {
me.mockReset(); me.mockReset();
@@ -98,3 +125,37 @@ describe("AuthGate", () => {
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument(); expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
}); });
}); });
describe("usePermissions / useWorkLanes", () => {
const as = async (state: unknown) => {
cleanup();
me.mockResolvedValue(state);
render(
<AuthGate>
<Probe />
</AuthGate>
);
return (await screen.findByText(/work:\d/)).textContent;
};
it("lets an admin and local mode do everything", async () => {
for (const state of [
{ mode: "local" },
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
]) {
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
}
});
it("lets an analyst read everything but work only in their own workspaces", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
"admin:false all:true mine:true theirs:false work:1"
);
});
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
"admin:false all:false mine:true theirs:true work:1,2"
);
});
});
@@ -178,6 +178,10 @@ describe("WorkspaceTree", () => {
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 }); expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
await screen.findByText("Session 8"); await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull(); expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
await user.click(screen.getByTestId("workspace-sessions-less-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
}); });
it("renames a session on double-click + Enter, and archives one from its row", async () => { it("renames a session on double-click + Enter, and archives one from its row", async () => {
@@ -76,9 +76,16 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
interface ConversationViewProps { interface ConversationViewProps {
sessionId: string; sessionId: string;
initialTranscriptId?: string | null; initialTranscriptId?: string | null;
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
} }
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) { export function ConversationView({
sessionId,
initialTranscriptId,
fill = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]); const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -91,6 +98,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]); const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false); const [showNewMsg, setShowNewMsg] = useState(false);
// Scrolled up from the end: a chat (`fill`) offers the way back down.
const [awayFromEnd, setAwayFromEnd] = useState(false);
// Track JSONL line numbers for incremental requests and history loading // Track JSONL line numbers for incremental requests and history loading
const lastLineRef = useRef(0); const lastLineRef = useRef(0);
@@ -370,6 +379,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Detect if at bottom // Detect if at bottom
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100; const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
isAtBottomRef.current = atBottom; isAtBottomRef.current = atBottom;
setAwayFromEnd(!atBottom);
// Hide "new messages" indicator when scrolled to bottom // Hide "new messages" indicator when scrolled to bottom
if (atBottom) { if (atBottom) {
@@ -390,10 +400,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps }, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div className="relative flex flex-col" style={{ minHeight: 0 }}> <div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can {/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */} refresh even when no messages have streamed yet. */}
{!loading && ( {!loading && !fill && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0"> <div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && ( {transcripts.length > 1 && (
<div className="relative"> <div className="relative">
@@ -441,7 +451,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
ref={scrollContainerRef} ref={scrollContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }} style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
> >
{/* History loading indicator */} {/* History loading indicator */}
{loadingHistory && ( {loadingHistory && (
@@ -474,8 +484,28 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
)} )}
</div> </div>
{/* Claude Desktop's round "back to the end" button */}
{fill && awayFromEnd && (
<button
type="button"
data-testid="conversation-to-end"
onClick={() => {
scrollToBottom();
setShowNewMsg(false);
}}
aria-label={t("conversation.newMessages")}
title={t("conversation.newMessages")}
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
>
<ArrowDown className="h-4 w-4" />
{showNewMsg && (
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
)}
</button>
)}
{/* New messages indicator */} {/* New messages indicator */}
{showNewMsg && ( {showNewMsg && !fill && (
<button <button
onClick={() => { onClick={() => {
scrollToBottom(); scrollToBottom();
@@ -381,6 +381,8 @@ interface MarkdownContentProps {
text: string; text: string;
/** Tighter spacing for nested contexts (list items, quotes). */ /** Tighter spacing for nested contexts (list items, quotes). */
dense?: boolean; dense?: boolean;
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
prose?: boolean;
} }
const HEADING_STYLES = [ const HEADING_STYLES = [
@@ -392,12 +394,16 @@ const HEADING_STYLES = [
"text-xs font-medium text-fg-secondary uppercase tracking-wider", "text-xs font-medium text-fg-secondary uppercase tracking-wider",
]; ];
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) { export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
const blocks = parseBlocks(text); const blocks = parseBlocks(text);
const gap = dense ? "space-y-1.5" : "space-y-2.5"; const gap = dense ? "space-y-1.5" : "space-y-2.5";
// A paragraph or list item takes the root's size and colour in prose.
const bodyText = prose ? "" : "text-sm text-fg-secondary";
return ( return (
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}> <div
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
>
{blocks.map((b, idx) => { {blocks.map((b, idx) => {
switch (b.kind) { switch (b.kind) {
case "code": case "code":
@@ -420,7 +426,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs" className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
> >
{b.items.map((item, i) => ( {b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary"> <li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)} {renderListItem(item, `li${idx}-${i}`)}
</li> </li>
))} ))}
@@ -433,7 +439,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60" className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
> >
{b.items.map((item, i) => ( {b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary"> <li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)} {renderListItem(item, `li${idx}-${i}`)}
</li> </li>
))} ))}
@@ -503,7 +509,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
case "para": case "para":
return ( return (
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words"> <p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
{renderInline(b.text, `p${idx}`)} {renderInline(b.text, `p${idx}`)}
</p> </p>
); );
@@ -111,7 +111,7 @@ const SENDER_STYLES: Record<
headerText: "text-status-warning", headerText: "text-status-warning",
}, },
}; };
import { ToolCallBlock } from "./ToolCallBlock"; import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent"; import { MarkdownContent } from "./MarkdownContent";
import { fmt, formatModelName } from "../../lib/format"; import { fmt, formatModelName } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments"; import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
@@ -264,6 +264,60 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
} }
} }
/** A text block of any sender: a task notification or skill body collapsed,
* TUI markup as segments, anything else as markdown. */
function renderTextBlock(text: string, key: number): React.ReactNode {
// Detect task notifications, collapsed by default
if (isTaskNotification(text)) {
return (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />}
title={i18n.t("sessions:conversation.taskNotification")}
borderClass="border-syntax-property/20"
bgClass="bg-syntax-property/5"
textClass="text-syntax-property"
/>
);
}
// Detect skill content, collapsed by default
if (isSkillContent(text)) {
const pathMatch = text.match(/^Base directory for this skill:\s*(\S+)/);
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
return (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
title={skillPath}
borderClass="border-syntax-builtin/20"
bgClass="bg-syntax-builtin/5"
textClass="text-syntax-builtin"
/>
);
}
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
// can appear inline (sometimes interleaved with prose). Parse the text
// into segments and render each with the appropriate visual treatment.
if (hasTuiTags(text)) {
const segments = parseTuiSegments(text);
return (
<div key={key} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={key} className="min-w-0">
<MarkdownContent text={stripAnsi(text)} />
</div>
);
}
/** Generic collapsible content block */ /** Generic collapsible content block */
function CollapsibleBlock({ function CollapsibleBlock({
text, text,
@@ -326,10 +380,46 @@ export function messageKeys(messages: TranscriptMessage[]): string[] {
}); });
} }
/** One thing on screen, in the Claude Desktop layout: a human message as a
* bubble, the assistant's prose bare, and everything it did between two
* pieces of prose — tool calls and thinking, across messages (Claude Code
* writes each as its own line) — as ONE collapsed line; any other sender
* keeps its attributed row. */
type Item =
| { kind: "event"; key: string; msg: TranscriptMessage }
| { kind: "row"; key: string; msg: TranscriptMessage }
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] };
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
const items: Item[] = [];
messages.forEach((msg, idx) => {
const key = keys[idx]!;
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
// Purely tool_result: shown inside its call's block.
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") return items.push({ kind: "user", key, msg });
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if (block.type === "text" && block.text?.trim()) {
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
}
});
});
return items;
}
export function MessageList({ messages, loading }: MessageListProps) { export function MessageList({ messages, loading }: MessageListProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const keys = useMemo(() => messageKeys(messages), [messages]); const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
if (loading) { if (loading) {
return ( return (
@@ -344,23 +434,280 @@ export function MessageList({ messages, loading }: MessageListProps) {
} }
return ( return (
<div className="space-y-3"> <div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
{messages.map((msg, idx) => { {items.map((item) => {
// Session lifecycle markers (e.g. /rename) render as a centered chip, switch (item.kind) {
// not as a user/assistant row. case "event":
if (msg.type === "session_event") { return (
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />; <SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
} }
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
return null;
}
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
})} })}
</div> </div>
); );
} }
const meta = "text-[10px] font-mono text-fg-muted";
/** The human's turn: a bubble on the right, its time on hover. A long one (a
* paste, a compaction summary) shows its first lines until opened. */
const LONG_BUBBLE = 800;
const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage }) {
const { t } = useTranslation("sessions");
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
const [open, setOpen] = useState(false);
return (
<div
data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
<div
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{msg.content.map((block, b) => {
if (block.type !== "text" || !block.text) return null;
// Plain typing reads as typed; anything structured keeps its rendering.
const plain =
!hasTuiTags(block.text) &&
!isSkillContent(block.text) &&
!isTaskNotification(block.text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{stripAnsi(block.text).trim()}
</p>
) : (
renderTextBlock(block.text, b)
);
})}
</div>
{long && (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="text-xs text-fg-muted hover:text-fg-secondary"
>
{open ? t("conversation.showLess") : t("conversation.showMore")}
</button>
)}
{msg.timestamp && (
<span className={`${meta} opacity-0 transition-opacity group-hover:opacity-100`}>
{formatLocalTime(msg.timestamp)}
</span>
)}
</div>
);
});
/** The assistant's prose, bare on the page; model, tokens and time on hover. */
const AssistantText = memo(function AssistantText({
msg,
text,
}: {
msg: TranscriptMessage;
text: string;
}) {
return (
<div
data-testid="chat-assistant"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
className="group min-w-0"
>
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
renderTextBlock(text, 0)
) : (
<MarkdownContent text={stripAnsi(text)} prose />
)}
<div
className={`${meta} mt-1 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100`}
>
{msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && (
<span>
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
</span>
)}
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
</div>
</div>
);
});
/** A muted line that opens to show what it stands for. */
function Disclosure({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 text-sm text-fg-muted transition-colors hover:text-fg-secondary"
>
{label}
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
</button>
{open && <div className="mt-2 space-y-2">{children}</div>}
</div>
);
}
/** The kind of work a tool call is, for the group's label (Claude Code and
* Codex names); anything else counts as a tool. */
const WORK_KIND: Record<string, string> = {
bash: "commands",
shell: "commands",
exec_command: "commands",
powershell: "commands",
edit: "edits",
multiedit: "edits",
write: "edits",
notebookedit: "edits",
apply_patch: "edits",
read: "reads",
grep: "searches",
glob: "searches",
ls: "searches",
websearch: "web",
webfetch: "web",
task: "agents",
agent: "agents",
};
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
export function workLabel(blocks: TranscriptContent[]): string {
const counts = new Map<string, number>();
for (const b of blocks) {
if (b.type !== "tool_use") continue;
const kind = WORK_KIND[(b.name ?? "").toLowerCase()] ?? "tools";
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
if (!counts.size) return i18n.t("sessions:conversation.thinking");
const text = [...counts]
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
.join(", ");
return text.charAt(0).toUpperCase() + text.slice(1);
}
/** One step inside an opened group, as Claude Desktop lists them: its own
* description (Claude Code's Bash `description`) or the tool and target as
* the title, the call itself under it in a capped scroll box, the result one
* click further. Thinking is a step too, closed. */
function WorkStep({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const thinking = block.type === "thinking";
const [open, setOpen] = useState(!thinking);
const [showResult, setShowResult] = useState(false);
const input = block.input as Record<string, unknown> | undefined;
const title = thinking
? t("conversation.thinking")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
return (
<div className="px-3 py-2">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={`flex w-full min-w-0 items-center gap-1 text-left text-sm transition-colors hover:text-fg-secondary ${
result?.is_error ? "text-status-danger" : "text-fg-muted"
}`}
>
<span className="truncate">{title}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
{open && (
<div className="mt-2 space-y-2">
{thinking ? (
<div className="max-h-60 overflow-auto text-fg-muted">
<MarkdownContent text={block.text ?? ""} dense />
</div>
) : (
<>
<div className="max-h-60 overflow-auto">
{/* The description is the title already. */}
{renderInput(
input ? { ...block, input: { ...input, description: undefined } } : block
)}
</div>
{result && (
<button
type="button"
aria-expanded={showResult}
onClick={() => setShowResult(!showResult)}
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-fg-secondary"
>
{result.is_error ? t("conversation.tool.error") : t("conversation.tool.output")}
<ChevronRight
className={`h-3 w-3 transition-transform ${showResult ? "rotate-90" : ""}`}
/>
</button>
)}
{result && showResult && (
<div className="max-h-60 overflow-auto">
{renderResult(result, block.name ?? "")}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/** What the assistant did between two replies as one line, opening onto
* one framed list of its steps. */
const WorkGroup = memo(function WorkGroup({
blocks,
toolResultMap,
}: {
blocks: TranscriptContent[];
toolResultMap: ReturnType<typeof buildToolResultMap>;
}) {
return (
<div data-testid="chat-tools">
<Disclosure label={workLabel(blocks)}>
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{blocks.map((block, i) => (
<WorkStep
key={block.id ?? i}
block={block}
result={block.id ? (toolResultMap.get(block.id) ?? null) : null}
/>
))}
</div>
</Disclosure>
</div>
);
});
/** One user/assistant row. Memoized: a live tail append re-renders only the /** One user/assistant row. Memoized: a live tail append re-renders only the
* new rows; its thinking-block expansion is its own state, kept by key. */ * new rows; its thinking-block expansion is its own state, kept by key. */
const MessageRow = memo(function MessageRow({ const MessageRow = memo(function MessageRow({
@@ -419,59 +766,7 @@ const MessageRow = memo(function MessageRow({
{/* Content blocks */} {/* Content blocks */}
{msg.content.map((block, bIdx) => { {msg.content.map((block, bIdx) => {
if (block.type === "text" && block.text) { if (block.type === "text" && block.text) return renderTextBlock(block.text, bIdx);
// Detect task notifications, collapsed by default
if (isTaskNotification(block.text)) {
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
}
title={t("conversation.taskNotification")}
borderClass="border-syntax-property/20"
bgClass="bg-syntax-property/5"
textClass="text-syntax-property"
/>
);
}
// Detect skill content, collapsed by default
if (isSkillContent(block.text)) {
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
title={skillPath}
borderClass="border-syntax-builtin/20"
bgClass="bg-syntax-builtin/5"
textClass="text-syntax-builtin"
/>
);
}
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
// can appear inline (sometimes interleaved with prose). Parse the text
// into segments and render each with the appropriate visual treatment.
if (hasTuiTags(block.text)) {
const segments = parseTuiSegments(block.text);
return (
<div key={bIdx} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={bIdx} className="min-w-0">
<MarkdownContent text={stripAnsi(block.text)} />
</div>
);
}
if (block.type === "thinking" && block.text) { if (block.type === "thinking" && block.text) {
const thinkKey = bIdx; const thinkKey = bIdx;
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
} }
/** Build a one-line summary of the tool call to show in the collapsed header. */ /** Build a one-line summary of the tool call to show in the collapsed header. */
function buildSummary(toolUse: TranscriptContent): string | null { export function buildSummary(toolUse: TranscriptContent): string | null {
const input = toolUse.input; const input = toolUse.input;
if (!input || typeof input !== "object" || "_truncated" in input) return null; if (!input || typeof input !== "object" || "_truncated" in input) return null;
const obj = input as Record<string, unknown>; const obj = input as Record<string, unknown>;
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`); const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
/** Render the input pane with tool-aware formatting. */ /** Render the input pane with tool-aware formatting. */
function renderInput(toolUse: TranscriptContent) { export function renderInput(toolUse: TranscriptContent) {
const input = toolUse.input; const input = toolUse.input;
if (!input) return null; if (!input) return null;
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
} }
/** Render the result pane: detect diff/json/text. */ /** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) { export function renderResult(toolResult: TranscriptContent, toolName: string) {
const text = toolResult.output ?? ""; const text = toolResult.output ?? "";
if (text.length === 0) if (text.length === 0)
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>; return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
@@ -1,13 +1,15 @@
/** /**
* @file MessageList.sender.test.tsx * @file MessageList.sender.test.tsx
* @description Verifies the transcript renders each message under its TRUE * @description Verifies the transcript renders each message by its TRUE
* sender label — User / Assistant / Main agent / System — instead of labeling * sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
* every `type:"user"` line "User" (reported transcript mis-attribution). * layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList } from "../MessageList"; import { MessageList, workLabel } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types"; import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage { function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -36,8 +38,8 @@ describe("MessageList — sender attribution", () => {
]; ];
render(<MessageList messages={messages} loading={false} />); render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument(); expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
expect(screen.getByText("Assistant")).toBeInTheDocument(); expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
expect(screen.getByText("System")).toBeInTheDocument(); expect(screen.getByText("System")).toBeInTheDocument();
expect(screen.getByText("Main agent")).toBeInTheDocument(); expect(screen.getByText("Main agent")).toBeInTheDocument();
}); });
@@ -48,7 +50,31 @@ describe("MessageList — sender attribution", () => {
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant" msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
]; ];
render(<MessageList messages={messages} loading={false} />); render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument(); expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
expect(screen.getByText("Assistant")).toBeInTheDocument(); expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
});
it("folds consecutive tool calls — one per transcript line — into one line", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
render(<MessageList messages={messages} loading={false} />);
const group = screen.getAllByTestId("chat-tools");
expect(group).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
expect(group[0]).toHaveTextContent("echo b");
});
it("names a mixed run by what it did, like Claude Desktop", () => {
const use = (name: string) => ({ type: "tool_use" as const, name });
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
"Ran 2 commands, edited a file, read a file"
);
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
}); });
}); });
+42 -28
View File
@@ -21,7 +21,8 @@
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets * commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block. * a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to * Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip above it, which hands the panel the selection's 1-based lines. * Chat chip at the end of its first line (inline, so it covers no code),
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape: * Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view. * CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -49,9 +50,7 @@ import {
highlightActiveLineGutter, highlightActiveLineGutter,
keymap, keymap,
lineNumbers, lineNumbers,
showTooltip,
type DecorationSet, type DecorationSet,
type Tooltip,
type ViewUpdate, type ViewUpdate,
} from "@codemirror/view"; } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
@@ -285,23 +284,41 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
}); });
} }
/** Add to Chat above a non-empty selection; `build` makes its DOM from the class ChatChip extends WidgetType {
* selection's first and last 1-based line. A selection ending at a line's constructor(
* start (a whole-line drag) does not take that line. */ readonly start: number,
readonly end: number,
readonly build: (start: number, end: number) => HTMLElement
) {
super();
}
eq(o: ChatChip) {
return this.start === o.start && this.end === o.end;
}
toDOM() {
return this.build(this.start, this.end);
}
}
/** Add to Chat inline at the end of a non-empty selection's first line —
* the empty space right of the code, so it covers no line. `build` makes its
* DOM from the selection's first and last 1-based line. A selection ending
* at a line's start (a whole-line drag) does not take that line. */
function chatChip(build: (start: number, end: number) => HTMLElement) { function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<Tooltip | null>({ return StateField.define<DecorationSet>({
create: () => null, create: () => Decoration.none,
update(tip, tr) { update(deco, tr) {
if (!tr.docChanged && !tr.selection) return tip; if (!tr.docChanged && !tr.selection) return deco;
const { from, to, empty } = tr.state.selection.main; const { from, to, empty } = tr.state.selection.main;
if (empty) return null; if (empty) return Decoration.none;
const doc = tr.state.doc; const doc = tr.state.doc;
const start = doc.lineAt(from).number; const first = doc.lineAt(from);
const last = doc.lineAt(to); const last = doc.lineAt(to);
const end = to === last.from && last.number > start ? last.number - 1 : last.number; const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) }; const widget = new ChatChip(first.number, end, build);
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
}, },
provide: (f) => showTooltip.from(f), provide: (f) => EditorView.decorations.from(f),
}); });
} }
@@ -319,8 +336,6 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
const M = "&.cm-merge-b.cm-merge-b"; const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a"; const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({ const theme = EditorView.theme({
// The chip draws its own box; drop its wrapper's default tooltip one.
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
"&": { "&": {
height: "100%", height: "100%",
fontSize: "11px", fontSize: "11px",
@@ -491,7 +506,11 @@ export default function DiffEditor({
useEffect(() => { useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined; const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme]; // A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
// would rewrite every line ending.
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
const editing = [ const editing = [
...common, ...common,
highlightActiveLineGutter(), highlightActiveLineGutter(),
@@ -513,9 +532,9 @@ export default function DiffEditor({
]), ]),
EditorView.contentAttributes.of({ "aria-label": label }), EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => { EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString()); if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
}), }),
blockBars(Text.of(original.split("\n")), buildBar), blockBars(Text.of(original.split(/\r?\n/)), buildBar),
blameSlot.of([]), blameSlot.of([]),
...(onAddToChat ...(onAddToChat
? [ ? [
@@ -525,16 +544,11 @@ export default function DiffEditor({
b.dataset.testid = "lane-editor-add-to-chat"; b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat"); b.textContent = cb.current.t("changes.addToChat");
b.className = b.className =
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2"; "ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click. // Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault(); b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end); b.onclick = () => cb.current.onAddToChat?.(start, end);
// CodeMirror makes the returned element the `.cm-tooltip`, whose return b;
// themed rule above would strip the button's own border and fill.
const wrap = document.createElement("div");
wrap.className = "cm-ah-chat";
wrap.append(b);
return wrap;
}), }),
] ]
: []), : []),
@@ -628,7 +642,7 @@ export default function DiffEditor({
// A value set from outside (Cancel's revert, a save's fresh text). // A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => { useEffect(() => {
const v = view.current; const v = view.current;
if (v && v.state.doc.toString() !== value) { if (v && v.state.sliceDoc() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } }); v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
} }
}, [value]); }, [value]);
-7
View File
@@ -104,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks; return locks;
} }
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
// A ring: a surface-4 fill vanished on the light themes.
idle: "border border-fg-muted",
dead: "bg-status-danger",
};
export function since(sec: number | null): string { export function since(sec: number | null): string {
if (sec === null) return "—"; if (sec === null) return "—";
if (sec < 60) return `${sec}s`; if (sec < 60) return `${sec}s`;
@@ -128,12 +128,12 @@ export function SessionReplay({
</div> </div>
)} )}
<div className="min-h-0 flex-1 overflow-hidden"> <div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} /> <ConversationView sessionId={sessionId} fill />
</div> </div>
{livePane ? ( {livePane ? (
<div <div
data-testid="session-replay-live" data-testid="session-replay-live"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary" className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
> >
<span className="min-w-0 flex-1">{t("replay.live")}</span> <span className="min-w-0 flex-1">{t("replay.live")}</span>
<button <button
@@ -148,7 +148,7 @@ export function SessionReplay({
) : session && !resumable ? ( ) : session && !resumable ? (
<div <div
data-testid="session-replay-no-resume" data-testid="session-replay-no-resume"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary" className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
> >
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span> <span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button <button
@@ -160,7 +160,7 @@ export function SessionReplay({
</button> </button>
</div> </div>
) : ( ) : (
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50"> <div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor <PromptEditor
bare bare
images={images} images={images}
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession"; import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList"; import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate";
/** A path without its trailing separators — `/` or a Windows `\`. */ /** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, ""); const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -52,6 +53,7 @@ export function SessionResumeButton({
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
const { lanes } = useLanes(); const { lanes } = useLanes();
const { canEditLane } = usePermissions();
const [runs, setRuns] = useState<RunHandle[] | null>(null); const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null); const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -77,7 +79,8 @@ export function SessionResumeButton({
? null ? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ?? : (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes)); owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null; // Server mode: an analyst may resume only in a workspace of their own.
if (!lane || !canEditLane(lane) || runs === null) return null;
const run = liveRunFor(lane, runs, session.id); const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => strip(a) === strip(b); const same = (a: string, b: string) => strip(a) === strip(b);
// Its own worktree counts too: the server resumes it there (`claude // Its own worktree counts too: the server resumes it there (`claude
@@ -776,6 +776,16 @@ function HomeBody({
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })} {t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
</button> </button>
)} )}
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
<button
type="button"
data-testid="workspace-recents-less"
onClick={() => setRecentLimit(RECENT_PAGE)}
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
<div className="mt-2"> <div className="mt-2">
<SessionFold <SessionFold
testId={`workspace-archived-${lane.id}`} testId={`workspace-archived-${lane.id}`}
@@ -923,6 +933,7 @@ function HomeBody({
}} }}
/> />
</div> </div>
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
{files.length === 0 ? ( {files.length === 0 ? (
<button <button
type="button" type="button"
@@ -15,13 +15,13 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`, * **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** while it is open — a closed one * which never has a session) as **Terminal** while it is open — a closed one
* leaves no row; Ctrl/Cmd+click opens * leaves no row; Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or * it in a split in the tab on screen) — attaching, never starting or
* resuming anything; * resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`, * any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a * `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context. * message is SENT, because a resume re-reads the whole transcript into context.
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that * The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one. A * loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session, * live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each * and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. * dot carries its meaning as screen-reader text.
@@ -568,6 +568,16 @@ export function WorkspaceSessionList({
{t("tree.seeAll", { count: more })} {t("tree.seeAll", { count: more })}
</button> </button>
)} )}
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
<button
type="button"
data-testid={`workspace-sessions-less-${lane.id}`}
onClick={() => setLimit(SESSION_LIMIT)}
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
{failure} {failure}
{confirmArchive} {confirmArchive}
{ctx.menu && ( {ctx.menu && (
@@ -255,6 +255,39 @@ describe("DiffEditor", () => {
); );
}); });
it.each(["unified", "split"] as const)(
"keeps a CRLF file's text as is (%s): no edit at mount, so the bars stay",
async (layout) => {
const onChange = vi.fn();
const { container } = render(
<DiffEditor
layout={layout}
value={"a\r\nB\r\nc"}
original={"a\r\nb\r\nc"}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={onChange}
onSave={vi.fn()}
onBlock={vi.fn()}
/>
);
await waitFor(() =>
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(1)
);
expect(onChange).not.toHaveBeenCalled();
const eds = container.querySelectorAll<HTMLElement>(".cm-editor");
const view = EditorView.findFromDOM(eds[eds.length - 1]!)!;
expect(view.state.sliceDoc()).toBe("a\r\nB\r\nc");
// Only B changed: the CRs are not read as an edit on every line.
expect(
container
.querySelectorAll(".cm-editor")
[eds.length - 1]!.querySelectorAll(".cm-changedLine")
).toHaveLength(1);
}
);
it("offers Add to Chat on a selection, with its 1-based line range", async () => { it("offers Add to Chat on a selection, with its 1-based line range", async () => {
const onAddToChat = vi.fn(); const onAddToChat = vi.fn();
const { container } = render( const { container } = render(
@@ -280,9 +313,10 @@ describe("DiffEditor", () => {
expect(b).not.toBeNull(); expect(b).not.toBeNull();
return b!; return b!;
}); });
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so // Inline at the end of the first selected line, never a tooltip over the
// the button must not be the `.cm-tooltip` itself. // line above it.
expect(chip.classList.contains("cm-tooltip")).toBe(false); expect(chip.closest(".cm-tooltip")).toBeNull();
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
fireEvent.click(chip); fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3); expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } }); view.dispatch({ selection: { anchor: 6 } });
@@ -397,6 +397,24 @@ describe("WorkspaceHome", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false }); expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
}); });
it("shows more recents a page at a time, and collapses them again", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => {
const all = (params as { archived?: boolean }).archived
? []
: Array.from({ length: 35 }, (_, i) => ({ ...sessions[1]!, id: `s-${i}` }));
const limit = (params as { limit?: number }).limit ?? 30;
return { sessions: all.slice(0, limit), total: all.length, limit, offset: 0 };
});
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByTestId("workspace-recents-more"));
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 60 });
await user.click(await screen.findByTestId("workspace-recents-less"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 30 });
expect(await screen.findByTestId("workspace-recents-more")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-recents-less")).toBeNull();
});
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => { it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderHome(); renderHome();
+2 -2
View File
@@ -262,8 +262,8 @@ export function splitDiff({
collapse?: { margin: number; minSize: number }; collapse?: { margin: number; minSize: number };
revertLabel: string; revertLabel: string;
}): { a: EditorView; b: EditorView; destroy: () => void } { }): { a: EditorView; b: EditorView; destroy: () => void } {
const origDoc = Text.of(original.split("\n")); const origDoc = Text.of(original.split(/\r?\n/));
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n"))); const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
const root = parent.appendChild(document.createElement("div")); const root = parent.appendChild(document.createElement("div"));
root.className = "flex h-full min-h-0"; root.className = "flex h-full min-h-0";
+1 -1
View File
@@ -19,7 +19,7 @@
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the * (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus) * workspace's home) at its left end, `actions` (the single pane's split menus)
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or * at its right end. 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, and a * a `+` item) brings it into the tab on screen, in the same order as a join, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any * 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 * 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. Dropped on the MIDDLE * bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
+27 -2
View File
@@ -12,6 +12,10 @@
* new lane's own live session for its slot, or an empty pane — never the * new lane's own live session for its slot, or an empty pane — never the
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a * previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
* tab) attaches to that tmux session only; nothing is started or resumed. * tab) attaches to that tmux session only; nothing is started or resumed.
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
* conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -23,6 +27,10 @@ import type { RunHandle, RunListResponse } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat";
import { useChatUi } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]);
export interface LaneConsolePaneProps { export interface LaneConsolePaneProps {
lanes: Lane[]; lanes: Lane[];
@@ -70,6 +78,12 @@ export function LaneConsolePane({
// The tool of the run on screen — a session's own, which need not be the // The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run. // workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude"; const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
const chat = useChatUi();
// A run learns its session id after it starts (Codex): the page's poll has it.
const chatSession = handle
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
: null;
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
useEffect(() => { useEffect(() => {
onHasActiveRunChange?.(handle !== null); onHasActiveRunChange?.(handle !== null);
@@ -173,11 +187,22 @@ export function LaneConsolePane({
)} )}
</div> </div>
) : ( ) : (
<div className="flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
{/* Over the terminal, never instead of it: unmounting would drop the attach. */}
{chatable && chat && (
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
<PaneChat
runId={handle.id}
sessionId={chatSession}
tool={handle.tool}
cwd={handle.cwd || currentLane?.cwd || ""}
/>
</div>
)}
<TerminalView <TerminalView
runId={handle.id} runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")} wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus} autoFocus={autoFocus && !(chatable && chat)}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) — // The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box) // the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of. // rather than leaving a dead terminal the user has to click out of.
+87
View File
@@ -0,0 +1,87 @@
/**
* @file PaneChat.tsx
* @description A live pane's Chat view, the way Claude Desktop shows a session:
* the session's conversation (`ConversationView`, kept current by its own poll)
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
* attached underneath — a message is sent through that terminal
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
* nothing new reaches the server. A permission prompt or a menu still needs the
* terminal; the hint under the box says so.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp } from "lucide-react";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { submitToRun } from "../../lib/panePaste";
import { toast } from "../../lib/toast";
export function PaneChat({
runId,
sessionId,
tool,
cwd,
}: {
runId: string;
sessionId: string;
tool: string;
cwd: string;
}) {
const { t } = useTranslation("run");
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false);
const text = promptWithImages(prompt, images);
const send = () => {
if (!text || uploading) return;
if (!submitToRun(runId, text)) {
toast.error(t("chat.notSent"));
return;
}
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
};
return (
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
value={prompt}
onChange={setPrompt}
onSubmit={send}
placeholder={t("chat.placeholder")}
rows={2}
slash={tool === "claude"}
fileCwd={cwd}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
);
}
+57 -10
View File
@@ -31,7 +31,10 @@
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may * clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
* READ the user's clipboard). While the socket is down a "Reconnecting…" * READ the user's clipboard). While the socket is down a "Reconnecting…"
* overlay says keystrokes are not reaching the pane; resizes reach tmux at * overlay says keystrokes are not reaching the pane; resizes reach tmux at
* most every 150 ms. * most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react"; import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
@@ -328,6 +331,25 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
// Another browser drives this pane's size: the grid it set, or null.
let follow: { cols: number; rows: number } | null = null;
// A follower's grid may not fit this container: shrink it (never grow).
const scaleToFit = () => {
const el = term.element;
const box = containerRef.current;
if (!el || !box) return;
el.style.transformOrigin = "0 0";
el.style.transform = "";
// `.xterm` is a block as wide as the box; the grid's size is the screen's.
const grid = el.querySelector<HTMLElement>(".xterm-screen");
if (!follow || !grid?.offsetWidth || !grid.offsetHeight) return;
const s = Math.min(
1,
box.clientWidth / grid.offsetWidth,
box.clientHeight / grid.offsetHeight
);
if (s < 1) el.style.transform = `scale(${s})`;
};
/** /**
* Tell the server this terminal's size. tmux draws the pane at whatever * Tell the server this terminal's size. tmux draws the pane at whatever
@@ -342,10 +364,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
* (`display:none` leaves the container 0×0), and fitting to that would * (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing. * resize the real tmux pane down to nothing.
*/ */
const sendSizeNow = () => { const sendSizeNow = (drive = false) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return; if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return; if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
};
// This browser is the one in use now: back to its own size, and the
// pane (and every other viewer) follows it.
const takeLead = () => {
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
follow = null;
scaleToFit();
fit.fit();
sendSizeNow(true);
}; };
// A divider drag or a window resize fires a resize per frame; tmux and the // 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 // program in the pane redraw on each, so the LAST size is sent once things
@@ -400,7 +432,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (data.startsWith('{"type"')) { if (data.startsWith('{"type"')) {
try { try {
const msg = JSON.parse(data); const msg = JSON.parse(data);
if (msg.type === "exit") { if (msg.type === "size") {
follow = { cols: msg.cols, rows: msg.rows };
term.resize(msg.cols, msg.rows);
requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") {
takeLead();
} else if (msg.type === "exit") {
ended = true; ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`); term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
onExitRef.current?.(); onExitRef.current?.();
@@ -429,6 +467,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
connect(); connect();
const dataDisposable = term.onData((data) => { const dataDisposable = term.onData((data) => {
// A keystroke takes the lead; a mouse report (wheel, hover) does not.
if (follow && !/^\x1b\[(<|M)/.test(data)) takeLead();
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
}); });
@@ -438,16 +478,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const resizeDisposable = term.onResize(sendSize); const resizeDisposable = term.onResize(sendSize);
// Re-assert on focus. Several attach clients can share one tmux session // 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 // (a second tab, another browser): coming back to this one makes it the
// the window to whichever client was last active — resizing this pane out // driver again, and the others follow its size.
// from under a terminal that is never told. Coming back to it says so. const reassert = takeLead;
const reassert = () => sendSizeNow();
term.textarea?.addEventListener("focus", reassert); term.textarea?.addEventListener("focus", reassert);
const refit = () => { const refit = () => {
// Never fit a collapsed console — see sendSize. // Never fit a collapsed console — see sendSize.
const el = containerRef.current; const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return; if (!el || !el.clientWidth || !el.clientHeight) return;
if (follow) return scaleToFit(); // the driver's grid, not ours
fit.fit(); // a real change fires onResize, which reports it fit.fit(); // a real change fires onResize, which reports it
}; };
const resizeObserver = new ResizeObserver(refit); const resizeObserver = new ResizeObserver(refit);
@@ -502,9 +542,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
() => () =>
registerPane( registerPane(
runId, runId,
(text) => { (text, submit) => {
termRef.current?.paste(text); termRef.current?.paste(text);
termRef.current?.focus(); // Enter a beat after the paste, so a TUI that times its paste
// detection never reads it as a newline inside the paste. A submit
// comes from the pane's Chat view, which keeps the keyboard.
if (submit)
setTimeout(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send("\r");
}, 100);
else termRef.current?.focus();
}, },
// On screen (a console hidden with CSS measures 0×0) AND attached: // On screen (a console hidden with CSS measures 0×0) AND attached:
// `onData` drops input while the socket is connecting or reconnecting, // `onData` drops input while the socket is connecting or reconnecting,
@@ -13,6 +13,7 @@ import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types"; import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools"; import { __resetAiTools } from "../../../hooks/useAiTools";
import { __resetChatUi } from "../../../lib/chatUi";
vi.mock("../TerminalView", () => ({ vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C // The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -24,8 +25,15 @@ vi.mock("../TerminalView", () => ({
), ),
})); }));
vi.mock("../PaneChat", () => ({
PaneChat: ({ sessionId }: { sessionId: string }) => (
<div data-testid="pane-chat" data-session-id={sessionId} />
),
}));
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
api: { api: {
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
lanes: { lanes: {
ensure: vi.fn(), ensure: vi.fn(),
action: vi.fn(), action: vi.fn(),
@@ -139,6 +147,34 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument(); expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
}); });
it("shows a Claude pane as a chat OVER its terminal when the viewer's Chat UI is on", async () => {
// Unmounting the terminal would drop the attach the chat sends through.
vi.mocked(api.me.prefs).mockResolvedValueOnce({
prefs: { "agenthub.chatUi": "1" },
onboardedAt: null,
});
__resetChatUi();
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-1",
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
__resetChatUi();
});
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => { it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" }; const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
const run = (id: string, laneId: number) => ({ const run = (id: string, laneId: number) => ({
@@ -69,6 +69,7 @@ vi.mock("@xterm/xterm", () => ({
attachCustomKeyEventHandler: keyHandlerMock, attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock, focus: focusMock,
paste: pasteMock, paste: pasteMock,
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})), })),
})); }));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe. // jsdom loads no stylesheet, so stand in for the CSS-variable probe.
@@ -368,7 +369,9 @@ describe("TerminalView", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!; const ws = MockWebSocket.instances[0]!;
ws.onopen?.(); ws.onopen?.();
expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 })); expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
}); });
it("reports a size change xterm made on its own, with no container resize", async () => { it("reports a size change xterm made on its own, with no container resize", async () => {
@@ -385,7 +388,34 @@ describe("TerminalView", () => {
onResizeHandlers.forEach((fn) => fn()); onResizeHandlers.forEach((fn) => fn());
expect(ws.sent).toEqual([]); expect(ws.sent).toEqual([]);
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 127, rows: 28 })]); expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 127, rows: 28, drive: false }),
]);
});
it("follows another browser's size until a keystroke here takes the lead", () => {
// Two browsers of different sizes on one pane: the one not in use renders
// the driver's grid instead of resizing tmux under it.
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
ws.onmessage?.({ data: '{"type":"size","cols":90,"rows":20}' });
expect([termState.cols, termState.rows]).toEqual([90, 20]);
ws.sent.length = 0;
onDataHandlers.forEach((fn) => fn("\x1b[<64;5;5M")); // a wheel notch
expect(ws.sent).toEqual(["\x1b[<64;5;5M"]);
onDataHandlers.forEach((fn) => fn("a"));
expect(ws.sent[1]).toBe(
JSON.stringify({ type: "resize", cols: 90, rows: 20, drive: true })
);
expect(ws.sent[2]).toBe("a");
} finally {
w.mockRestore();
h.mockRestore();
}
}); });
it("never reports a collapsed console's zero size", () => { it("never reports a collapsed console's zero size", () => {
@@ -10,6 +10,8 @@
* (polled from GET /api/ai-tools/job; a finished job folds to one line). * (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default / * A right-click on an installed row opens its Update / Set default /
* Enable-Disable (plus Copy path) as a menu; the buttons stay. * Enable-Disable (plus Copy path) as a menu; the buttons stay.
* A click on an installed row (outside its buttons) opens that tool's settings
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
* Refresh re-reads the login shell's PATH and re-detects. Icons come from * Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep * `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
* working. * working.
@@ -19,12 +21,14 @@
import { Switch } from "../Switch"; import { Switch } from "../Switch";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react"; import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { api, type AiTool, type AiToolJob } from "../../lib/api"; import { api, type AiTool, type AiToolJob } from "../../lib/api";
import { usePermissions } from "../AuthGate";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" }; type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
); );
} }
export function AiToolsSettings() { export function AiToolsSettings({
settingsHref,
}: {
/** A tool's own settings page, when it has one. */
settingsHref?: (id: string) => string | undefined;
} = {}) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const navigate = useNavigate();
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools(); const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
// Installing, updating and switching tools changes the host: an admin's.
const { isAdmin } = usePermissions();
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null); const [job, setJob] = useState<AiToolJob | null>(null);
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
{t("aiTools.installed", { count: installed.length })} {t("aiTools.installed", { count: installed.length })}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{updateAllTools.length > 0 && ( {isAdmin && updateAllTools.length > 0 && (
<button <button
type="button" type="button"
onClick={() => setConfirm({ kind: "update-all" })} onClick={() => setConfirm({ kind: "update-all" })}
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
{t("aiTools.updateAll", { count: updateAllTools.length })} {t("aiTools.updateAll", { count: updateAllTools.length })}
</button> </button>
)} )}
<button {isAdmin && (
type="button" <button
onClick={() => void run("refresh", refresh)} type="button"
disabled={busy !== null} onClick={() => void run("refresh", refresh)}
className={GHOST_BTN} disabled={busy !== null}
> className={GHOST_BTN}
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} /> >
{t("aiTools.refresh")} <RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
</button> {t("aiTools.refresh")}
</button>
)}
</div> </div>
</div> </div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>} {error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2"> <ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => { {installed.map((tool) => {
const isDefault = tool.id === defaultId; const isDefault = tool.id === defaultId;
const href = settingsHref?.(tool.id);
return ( return (
<li <li
key={tool.id} key={tool.id}
data-testid={`ai-tool-${tool.id}`} data-testid={`ai-tool-${tool.id}`}
onContextMenu={(e) => ctx.open(e, tool)} onContextMenu={(e) => ctx.open(e, tool)}
className="px-3 py-2.5" onClick={(e) => {
// The row's own buttons, switch, links and log keep their click.
if (href && !(e.target as Element).closest("button, a, input, pre"))
navigate(href);
}}
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
<AiToolIcon tool={tool} /> <AiToolIcon tool={tool} />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary"> <div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span> {href ? (
<Link
to={href}
title={t("aiTools.openSettings", { tool: tool.label })}
className="truncate hover:text-accent hover:underline"
>
{tool.label}
</Link>
) : (
<span className="truncate">{tool.label}</span>
)}
<Homepage tool={tool} /> <Homepage tool={tool} />
{isDefault && ( {isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent"> <span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
</div> </div>
</div> </div>
{/* Fixed slots, so the buttons line up from row to row. */} {/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end"> {isAdmin && (
{tool.update && ( <>
<button <div className="flex min-w-20 shrink-0 justify-end">
type="button" {tool.update && (
disabled={jobBusy} <button
onClick={() => setConfirm({ kind: "update", tool })} type="button"
className={GHOST_BTN} disabled={jobBusy}
> onClick={() => setConfirm({ kind: "update", tool })}
{t("aiTools.update")} className={GHOST_BTN}
</button> >
)} {t("aiTools.update")}
</div> </button>
<div className="flex min-w-28 shrink-0 justify-end"> )}
{!isDefault && ( </div>
<button <div className="flex min-w-28 shrink-0 justify-end">
type="button" {!isDefault && (
disabled={!tool.enabled || busy !== null} <button
onClick={() => setDefault(tool)} type="button"
className={GHOST_BTN} disabled={!tool.enabled || busy !== null}
> onClick={() => setDefault(tool)}
{t("aiTools.setDefault")} className={GHOST_BTN}
</button> >
)} {t("aiTools.setDefault")}
</div> </button>
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} /> )}
</div>
<ToolSwitch
tool={tool}
disabled={busy !== null}
onToggle={() => toggle(tool)}
/>
</>
)}
</div> </div>
{logFor(tool.id)} {logFor(tool.id)}
</li> </li>
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
</ul> </ul>
{ctx.menu && ( {ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu"> <ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
{ctx.menu.item.update && ( {isAdmin && ctx.menu.item.update && (
<MenuItem <MenuItem
disabled={jobBusy} disabled={jobBusy}
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))} onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
{t("aiTools.update")} {t("aiTools.update")}
</MenuItem> </MenuItem>
)} )}
{ctx.menu.item.id !== defaultId && ( {isAdmin && ctx.menu.item.id !== defaultId && (
<MenuItem <MenuItem
disabled={!ctx.menu.item.enabled || busy !== null} disabled={!ctx.menu.item.enabled || busy !== null}
onSelect={pick(setDefault)} onSelect={pick(setDefault)}
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
{t("aiTools.setDefault")} {t("aiTools.setDefault")}
</MenuItem> </MenuItem>
)} )}
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}> {isAdmin && (
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")} <MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
</MenuItem> {ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
)}
{ctx.menu.item.path && ( {ctx.menu.item.path && (
<> <>
<MenuSeparator /> {isAdmin && <MenuSeparator />}
<MenuItem <MenuItem
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))} onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
> >
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
</span> </span>
</span> </span>
<Homepage tool={tool} /> <Homepage tool={tool} />
{tool.install && ( {isAdmin && tool.install && (
<button <button
type="button" type="button"
disabled={jobBusy} disabled={jobBusy}
@@ -3,6 +3,7 @@
* @description Settings → Appearance (`/settings/appearance`): the interface language, the * @description Settings → Appearance (`/settings/appearance`): the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the * terminal font (`lib/terminalFont.ts`, with a preview line), the
* Dark / Light / System mode switch and one named-theme picker per mode, and the * Dark / Light / System mode switch and one named-theme picker per mode, and the
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet * corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in * pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its * `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
@@ -17,6 +18,7 @@ import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react"; import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow"; import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings"; import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme"; import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
@@ -146,6 +148,8 @@ export function AppearanceSettings() {
</p> </p>
</SettingsSection> </SettingsSection>
<ChatUiSettings />
<section id="theme" className="card p-5 space-y-5 scroll-mt-24"> <section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3> <h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow <SettingsRow
@@ -7,7 +7,9 @@
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and * tray Quit): `POST /api/settings/quit` closes every workspace terminal and
* stops the server, which stays stopped until `agenthub start` — or the next * stops the server, which stays stopped until `agenthub start` — or the next
* boot/login while the switch is on. Between them, the status-area icon switch * boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). * (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -74,6 +76,24 @@ export function AutostartSettings() {
} }
}; };
const [uninstalling, setUninstalling] = useState(false);
const uninstall = async () => {
const ok = await confirmDialog({
title: t("uninstall.confirmTitle"),
message: t("uninstall.confirmMessage"),
confirmLabel: t("uninstall.button"),
});
if (!ok) return;
setUninstalling(true);
try {
const { log } = await api.settings.uninstall();
toast.success(t("uninstall.done", { log }));
} catch (err) {
setUninstalling(false);
toast.error(err instanceof Error ? err.message : String(err));
}
};
const note = !status const note = !status
? undefined ? undefined
: !status.available : !status.available
@@ -113,6 +133,21 @@ export function AutostartSettings() {
</button> </button>
} }
/> />
<SettingsRow
label={t("uninstall.label")}
description={t("uninstall.desc")}
control={
<button
type="button"
data-testid="uninstall-agenthub"
disabled={uninstalling}
onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
>
{t("uninstall.button")}
</button>
}
/>
</SettingsSection> </SettingsSection>
); );
} }
@@ -0,0 +1,202 @@
/**
* @file BackupSettings.tsx
* @description Settings → Data → Backup: the daily database backup of
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
* while it runs, then toasts the outcome) and the newest backup in the folder.
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
* field the environment set is read-only. Restoring is manual and the card says
* how.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, DatabaseBackup } from "lucide-react";
import { api } from "../../lib/api";
import type { BackupState } from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { Select } from "../Select";
import { SettingsSection } from "./SettingsRow";
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
export function BackupSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<BackupState | null>(null);
const [dir, setDir] = useState("");
const [hour, setHour] = useState(2);
const [keep, setKeep] = useState("7");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const apply = (s: BackupState) => {
setState(s);
setDir(s.dir);
setHour(s.hour);
setKeep(String(s.keep));
};
useEffect(() => {
api.settings.backup
.get()
.then(apply)
.catch(() => undefined);
}, []);
// A backup takes tens of seconds on a large database: poll until it ends.
const running = !!state?.running;
useEffect(() => {
if (!running) return;
const id = window.setInterval(() => {
api.settings.backup
.get()
.then((s) => {
setState(s); // the form keeps its drafts
if (s.running || !s.last) return;
if (s.last.ok) toast.success(t("backup.done", { file: s.last.file }));
else toast.error(t("backup.failed", { error: s.last.error }));
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [running, t]);
const keepN = Number(keep);
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
const patch: { dir?: string; hour?: number; keep?: number } = {};
if (state) {
if (dir.trim() !== state.dir) patch.dir = dir.trim();
if (hour !== state.hour) patch.hour = hour;
if (keepN !== state.keep) patch.keep = keepN;
}
const changed = Object.keys(patch).length > 0;
async function save() {
setSaving(true);
setError(null);
try {
apply(await api.settings.backup.save(patch));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function runNow() {
try {
setState(await api.settings.backup.run());
} catch (err) {
toast.error(err);
}
}
const owned = state?.env_owned;
const newest = state?.files[0];
const total = state?.files.reduce((n, f) => n + f.size, 0) ?? 0;
return (
<SettingsSection
id="backup"
title={
<span className="flex items-center gap-2">
<DatabaseBackup className="w-4 h-4 text-fg-muted" />
{t("backup.title")}
</span>
}
description={t("backup.description")}
>
<div className="py-3 space-y-3">
<label className="block text-xs text-fg-muted">
{t("backup.folder")}
<input
className="input mt-1 w-full font-mono text-xs"
placeholder="/mnt/hdd/agenthub-backups"
value={dir}
disabled={!state || owned?.dir}
onChange={(e) => {
setDir(e.target.value);
setError(null);
}}
/>
</label>
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="w-28">
<Select
value={String(hour)}
onChange={(v) => setHour(Number(v))}
label={t("backup.hour")}
disabled={!state || owned?.hour}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
<label className="text-xs text-fg-muted">
{t("backup.keep")}
<input
type="number"
min={1}
max={365}
step={1}
className="input mt-1 block w-24 text-sm"
value={keep}
disabled={!state || owned?.keep}
onChange={(e) => setKeep(e.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || !keepValid}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
<button
type="button"
className="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!state?.dir || running || changed}
onClick={() => void runNow()}
>
<DatabaseBackup className={`w-4 h-4 ${running ? "animate-pulse" : ""}`} />{" "}
{running ? t("backup.running") : t("backup.runNow")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
<p className="text-xs text-fg-secondary">
{newest
? t("backup.newest", {
time: formatDateTime(newest.mtime),
size: formatBytes(newest.size),
n: state?.files.length,
total: formatBytes(total),
})
: state?.dir
? t("backup.none")
: t("backup.off")}
</p>
{state?.last && !state.last.ok && (
<p className="text-xs text-status-danger">
{t("backup.lastFailed", {
time: formatDateTime(state.last.at),
error: state.last.error,
})}
</p>
)}
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
{(owned?.dir || owned?.hour || owned?.keep) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
</div>
</SettingsSection>
);
}
@@ -0,0 +1,33 @@
/**
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
* account on a shared dashboard keeps its own choice on any browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { saveChatUi, useChatUi } from "../../lib/chatUi";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChatUiSettings() {
const { t } = useTranslation("settings");
const chatUi = useChatUi();
return (
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
<SettingsSwitchRow
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
)
)
}
/>
</SettingsSection>
);
}
+47 -39
View File
@@ -33,6 +33,7 @@ import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets"; import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api"; import type { PetView } from "../../lib/api";
import { usePermissions } from "../AuthGate";
const PREVIEW = 72; const PREVIEW = 72;
@@ -121,6 +122,8 @@ export function PetSettings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const worn = useWornPet(); const worn = useWornPet();
const { list, own, installed, busy, error, wear, install, add, remove } = usePets(); const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Pets are stored for the whole install: adding or deleting one is an admin's.
const { isAdmin } = usePermissions();
const imageInput = useRef<HTMLInputElement>(null); const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null); const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id); const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
@@ -219,6 +222,7 @@ export function PetSettings() {
/> />
{list?.featured.map((f) => { {list?.featured.map((f) => {
const have = installed.get(f.id); const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return ( return (
<PetCard <PetCard
key={f.id} key={f.id}
@@ -252,50 +256,54 @@ export function PetSettings() {
disabled={busy !== null} disabled={busy !== null}
preview={<Preview pet={p} size={PREVIEW} />} preview={<Preview pet={p} size={PREVIEW} />}
onPick={() => wear(p)} onPick={() => wear(p)}
onDelete={() => void remove(p)} onDelete={isAdmin ? () => void remove(p) : undefined}
deleteLabel={t("sagi.pet.delete", { name: p.displayName })} deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
/> />
))} ))}
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-2"> {isAdmin && (
<button <>
type="button" <div className="mt-4 flex flex-wrap items-center gap-2">
className="btn-ghost text-xs" <button
disabled={busy !== null} type="button"
onClick={() => imageInput.current?.click()} className="btn-ghost text-xs"
> disabled={busy !== null}
{busy === "image" ? ( onClick={() => imageInput.current?.click()}
<Loader2 className="h-3.5 w-3.5 animate-spin" /> >
) : ( {busy === "image" ? (
<Upload className="h-3.5 w-3.5" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
)} ) : (
{t("sagi.pet.upload")} <Upload className="h-3.5 w-3.5" />
</button> )}
<button {t("sagi.pet.upload")}
type="button" </button>
className="btn-ghost text-xs" <button
disabled={busy !== null} type="button"
onClick={() => bundleInput.current?.click()} className="btn-ghost text-xs"
> disabled={busy !== null}
{busy === "bundle" ? ( onClick={() => bundleInput.current?.click()}
<Loader2 className="h-3.5 w-3.5 animate-spin" /> >
) : ( {busy === "bundle" ? (
<PackageOpen className="h-3.5 w-3.5" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
)} ) : (
{t("sagi.pet.import")} <PackageOpen className="h-3.5 w-3.5" />
</button> )}
<a {t("sagi.pet.import")}
href="https://codex-pets.net/" </button>
target="_blank" <a
rel="noreferrer noopener" href="https://codex-pets.net/"
className="btn-ghost text-xs" target="_blank"
> rel="noreferrer noopener"
<ExternalLink className="h-3.5 w-3.5" /> className="btn-ghost text-xs"
{t("sagi.pet.browse")} >
</a> <ExternalLink className="h-3.5 w-3.5" />
</div> {t("sagi.pet.browse")}
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p> </a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
</>
)}
{error && ( {error && (
<p role="alert" className="mt-2 text-xs text-status-danger"> <p role="alert" className="mt-2 text-xs text-status-danger">
{error} {error}
@@ -5,7 +5,8 @@
* a homepage link, Refresh re-detects, every updater gets Update without a version * a homepage link, Refresh re-detects, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled * lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done. An installed row's right-click menu runs * job log, folded once it is done. An installed row's right-click menu runs
* the same Set default / enable switch as its controls. * the same Set default / enable switch as its controls. A click on a row (or
* its name link) opens the tool's settings page; its buttons do not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -13,6 +14,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { AiToolsSettings } from "../AiToolsSettings"; import { AiToolsSettings } from "../AiToolsSettings";
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api"; import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools"; import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -92,9 +94,48 @@ const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
...over, ...over,
}); });
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname}</span>;
}
describe("AiToolsSettings", () => { describe("AiToolsSettings", () => {
it("opens a tool's settings from its row or name, never from its buttons", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/settings/agents"]}>
<Routes>
<Route
path="*"
element={
<>
<AiToolsSettings
settingsHref={(id) => (id === "gemini" ? `/settings/agents/${id}` : undefined)}
/>
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByText("Set default"));
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
await user.click(gemini);
expect(screen.getByTestId("where").textContent).toBe("/settings/agents/gemini");
expect(within(gemini).getByRole("link", { name: "Gemini" })).toHaveAttribute(
"href",
"/settings/agents/gemini"
);
// A tool without a page has no link.
expect(
within(screen.getByTestId("ai-tool-claude")).queryByRole("link", { name: "Claude Code" })
).toBeNull();
});
it("splits installed tools from the ones available to install", async () => { it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument(); expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" })); await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
const claude = screen.getByTestId("ai-tool-claude"); const claude = screen.getByTestId("ai-tool-claude");
@@ -107,7 +148,7 @@ describe("AiToolsSettings", () => {
it("toggles a tool and sets the default through the API", async () => { it("toggles a tool and sets the default through the API", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini"); const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" })); await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false }); expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
@@ -121,7 +162,7 @@ describe("AiToolsSettings", () => {
it("refreshes detection", async () => { it("refreshes detection", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude"); await screen.findByTestId("ai-tool-claude");
await user.click(screen.getByRole("button", { name: "Refresh" })); await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce(); expect(api.aiTools.refresh).toHaveBeenCalledOnce();
@@ -129,7 +170,7 @@ describe("AiToolsSettings", () => {
it("sets the default and disables a tool from its right-click menu", async () => { it("sets the default and disables a tool from its right-click menu", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini"); const gemini = await screen.findByTestId("ai-tool-gemini");
await user.pointer({ keys: "[MouseRight]", target: gemini }); await user.pointer({ keys: "[MouseRight]", target: gemini });
await user.click(screen.getByRole("menuitem", { name: "Set default" })); await user.click(screen.getByRole("menuitem", { name: "Set default" }));
@@ -182,7 +223,7 @@ describe("AiToolsSettings — install and update", () => {
vi.mocked(api.aiTools.job) vi.mocked(api.aiTools.job)
.mockResolvedValueOnce(null) .mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" })); .mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: /Available to install/ })); await user.click(await screen.findByRole("button", { name: /Available to install/ }));
const qwen = await screen.findByTestId("ai-tool-qwen"); const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" })); await user.click(within(qwen).getByRole("button", { name: "Install" }));
@@ -212,7 +253,7 @@ describe("AiToolsSettings — install and update", () => {
it("names the vendor script for a script install", async () => { it("names the vendor script for a script install", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable(); await openAvailable();
const cursor = await screen.findByTestId("ai-tool-cursor"); const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" })); await user.click(within(cursor).getByRole("button", { name: "Install" }));
@@ -222,7 +263,7 @@ describe("AiToolsSettings — install and update", () => {
}); });
it("offers Update on every installed tool with an updater", async () => { it("offers Update on every installed tool with an updater", async () => {
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
for (const id of ["claude", "codex", "gemini"]) { for (const id of ["claude", "codex", "gemini"]) {
const row = await screen.findByTestId(`ai-tool-${id}`); const row = await screen.findByTestId(`ai-tool-${id}`);
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument(); expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
@@ -231,7 +272,7 @@ describe("AiToolsSettings — install and update", () => {
it("Update all lists every installed tool's updater", async () => { it("Update all lists every installed tool's updater", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: "Update all (3)" })); await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
const cmd = screen.getByTestId("ai-tool-confirm-command"); const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update"); expect(cmd).toHaveTextContent("claude update");
@@ -241,13 +282,13 @@ describe("AiToolsSettings — install and update", () => {
it("hides Update all when no tool has an updater", async () => { it("hides Update all when no tool has an updater", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW); vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />); render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude"); await screen.findByTestId("ai-tool-claude");
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
}); });
it("falls back to the letter when the icon fails", async () => { it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />); const { container } = render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable(); await openAvailable();
await screen.findByTestId("ai-tool-qwen"); await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!; const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
@@ -2,7 +2,8 @@
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks * @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
* first, says when start-at-login will bring the dashboard back, and only a * first, says when start-at-login will bring the dashboard back, and only a
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon * confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
* switch, which goes straight to PUT /api/settings/tray. * switch, which goes straight to PUT /api/settings/tray, and Uninstall
* AgentHub, which also asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -17,6 +18,7 @@ vi.mock("../../../lib/api", () => ({
autostart: { get: vi.fn(), set: vi.fn() }, autostart: { get: vi.fn(), set: vi.fn() },
tray: { get: vi.fn(), set: vi.fn() }, tray: { get: vi.fn(), set: vi.fn() },
quit: vi.fn(), quit: vi.fn(),
uninstall: vi.fn(),
}, },
}, },
})); }));
@@ -65,3 +67,30 @@ describe("Quit AgentHub", () => {
await waitFor(() => expect(icon).toBeChecked()); await waitFor(() => expect(icon).toBeChecked());
}); });
}); });
describe("Uninstall AgentHub", () => {
beforeEach(() => {
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
vi.mocked(api.settings.uninstall)
.mockReset()
.mockResolvedValue({ ok: true, log: "/tmp/agenthub-uninstall-1.log" });
});
it("does nothing when the ask is cancelled", async () => {
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
expect(api.settings.uninstall).not.toHaveBeenCalled();
});
it("uninstalls once confirmed, saying the database goes too", async () => {
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(api.settings.uninstall).toHaveBeenCalledTimes(1));
expect(ask.mock.calls[0]![0].message).toMatch(/database/);
expect(screen.getByTestId("uninstall-agenthub")).toBeDisabled();
});
});
@@ -0,0 +1,65 @@
/**
* @file BackupSettings.test.tsx
* @description Settings → Data → Backup: a save sends only the fields that
* changed, "Back up now" is off until a folder is saved, and the newest backup
* in the folder is shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { backup } = vi.hoisted(() => ({
backup: { get: vi.fn(), save: vi.fn(), run: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({ api: { settings: { backup } } }));
import { BackupSettings } from "../BackupSettings";
const off = {
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("BackupSettings", () => {
it("saves only the folder when only the folder changed, then allows a run", async () => {
const user = userEvent.setup();
backup.get.mockResolvedValue(off);
backup.save.mockImplementation((p) => Promise.resolve({ ok: true, ...off, ...p }));
render(<BackupSettings />);
const run = await screen.findByRole("button", { name: /Back up now/ });
expect(run).toBeDisabled();
expect(screen.getByText("Backups are off.")).toBeInTheDocument();
await user.type(screen.getByLabelText("Backup folder"), "/mnt/hdd/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ dir: "/mnt/hdd/b" }));
await waitFor(() => expect(run).toBeEnabled());
});
it("shows the newest backup", async () => {
backup.get.mockResolvedValue({
...off,
dir: "/mnt/hdd/b",
files: [
{ name: "agenthub-20261008-020000.db", size: 2048, mtime: "2026-10-08T02:00:30Z" },
{ name: "agenthub-20261007-020000.db", size: 1024, mtime: "2026-10-07T02:00:30Z" },
],
});
render(<BackupSettings />);
expect(await screen.findByText(/2\.0 KB · 2 kept, 3\.0 KB in all/)).toBeInTheDocument();
});
});
+22 -1
View File
@@ -6,14 +6,17 @@
* refetches), and a WebSocket reconnect refetches the whole list because the * refetches), and a WebSocket reconnect refetches the whole list because the
* frames missed while disconnected are never replayed. The copy is dropped * frames missed while disconnected are never replayed. The copy is dropped
* when the last component unmounts, so a fresh mount always reads fresh rows. * when the last component unmounts, so a fresh mount always reads fresh rows.
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
* work in (server mode: an analyst's own), for the Workspace page and tree.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useMemo, useSyncExternalStore } from "react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame"; import { keepIfSame } from "../lib/keepIfSame";
import type { Lane, WSMessage } from "../lib/types"; import type { Lane, WSMessage } from "../lib/types";
import { useAuth } from "../components/AuthGate";
let lanes: Lane[] | null = null; let lanes: Lane[] | null = null;
// Bumped when the store is torn down, so a reply to a request made before the // Bumped when the store is torn down, so a reply to a request made before the
@@ -92,3 +95,21 @@ export function useLanes() {
}, []); }, []);
return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load }; return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load };
} }
/**
* The workspaces this user WORKS in — the Workspace page and its sidebar tree.
* Server mode: an analyst gets every workspace from the API (to read) but may
* change only their own, so only those are listed here; other users' work is
* read on the Sessions, Analytics and Users pages.
*/
export function useWorkLanes() {
const all = useLanes();
const { state } = useAuth();
const analystId =
state.mode === "server" && state.user?.role === "analyst" ? state.user.id : null;
const lanes = useMemo(
() => (analystId == null ? all.lanes : all.lanes.filter((l) => l.owner_id === analystId)),
[all.lanes, analystId]
);
return { ...all, lanes };
}
+1 -2
View File
@@ -23,8 +23,7 @@ describe("i18n resources", () => {
await i18n.changeLanguage("vi"); await i18n.changeLanguage("vi");
expect(i18n.t("nav:dashboard")).toBe("Tổng quan"); expect(i18n.t("nav:dashboard")).toBe("Tổng quan");
expect(i18n.t("nav:agentBoard")).toBe("Bảng Kanban"); expect(i18n.t("nav:settings")).toBe("Cài đặt");
expect(i18n.t("nav:languageShort.vi")).toBe("VI");
}); });
it("should keep Agent terminology untranslated in the vi locale", async () => { it("should keep Agent terminology untranslated in the vi locale", async () => {
@@ -4,12 +4,9 @@
"export": "Export", "export": "Export",
"totalSessions": "Total Sessions", "totalSessions": "Total Sessions",
"totalAgents": "Total Agents", "totalAgents": "Total Agents",
"totalTokens": "Total Tokens",
"totalCost": "Total Cost", "totalCost": "Total Cost",
"totalEvents": "Total Events", "totalEvents": "Total Events",
"cacheHitRate": "% cache hit rate",
"perSession": " per session", "perSession": " per session",
"noDataCost": "No data",
"eventCountLabel_one": "{{count}} event", "eventCountLabel_one": "{{count}} event",
"eventCountLabel_other": "{{count}} events", "eventCountLabel_other": "{{count}} events",
"tabs": { "tabs": {
+5 -36
View File
@@ -30,14 +30,12 @@
}, },
"overview": { "overview": {
"rootsTitle": "Roots", "rootsTitle": "Roots",
"claudeHome": "Claude home",
"projectClaudeDir": "Project .claude", "projectClaudeDir": "Project .claude",
"projectRoot": "Project root", "projectRoot": "Project root",
"claudeJson": "claude.json", "claudeJson": "claude.json",
"summary": "Quick summary", "summary": "Quick summary",
"user": "user", "user": "user",
"project": "project", "project": "project"
"items": "items"
}, },
"common": { "common": {
"search": "Search by name…", "search": "Search by name…",
@@ -47,32 +45,19 @@
"close": "Close", "close": "Close",
"copyPath": "Copy path", "copyPath": "Copy path",
"copyName": "Copy name", "copyName": "Copy name",
"copied": "Copied",
"truncated": "(truncated for preview)", "truncated": "(truncated for preview)",
"size": "Size",
"modified": "Modified",
"scopeLabel": "Scope", "scopeLabel": "Scope",
"noFrontmatter": "No frontmatter",
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.", "redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.",
"preview": "Preview", "preview": "Preview",
"raw": "Raw" "raw": "Raw"
}, },
"skills": {
"name": "Name",
"description": "Description"
},
"agents": { "agents": {
"name": "Name", "name": "Name",
"model": "Model",
"tools": "Tools" "tools": "Tools"
}, },
"plugins": { "plugins": {
"manifest": "Manifest",
"marketplace": "Marketplace",
"version": "Version",
"installedAt": "Installed", "installedAt": "Installed",
"lastUpdated": "Updated", "lastUpdated": "Updated",
"installPath": "Install path",
"missing": "Install path missing", "missing": "Install path missing",
"manifestMissing": "No installed_plugins.json found at {{path}}", "manifestMissing": "No installed_plugins.json found at {{path}}",
"enabled": "Enabled", "enabled": "Enabled",
@@ -90,8 +75,7 @@
"hooks_other": "{{count}} hook scripts", "hooks_other": "{{count}} hook scripts",
"author": "Author", "author": "Author",
"homepage": "Homepage", "homepage": "Homepage",
"license": "License", "license": "License"
"description": "Description"
}, },
"marketplaces": { "marketplaces": {
"manifest": "Known marketplaces manifest", "manifest": "Known marketplaces manifest",
@@ -100,11 +84,9 @@
"lastUpdated": "Last synced", "lastUpdated": "Last synced",
"pluginCount": "Plugins", "pluginCount": "Plugins",
"owner": "Owner", "owner": "Owner",
"installLocation": "Install path",
"addCmd": "claude plugin marketplace add <repo>" "addCmd": "claude plugin marketplace add <repo>"
}, },
"keybindings": { "keybindings": {
"file": "Source file",
"missing": "No keybindings.json found at {{path}}", "missing": "No keybindings.json found at {{path}}",
"context": "Context", "context": "Context",
"key": "Key", "key": "Key",
@@ -128,8 +110,7 @@
}, },
"hookScripts": { "hookScripts": {
"title": "~/.claude/hooks/ scripts", "title": "~/.claude/hooks/ scripts",
"subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them.", "subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them."
"empty": "No scripts in ~/.claude/hooks/."
}, },
"mcp": { "mcp": {
"userScope": "User-scoped servers", "userScope": "User-scoped servers",
@@ -143,8 +124,6 @@
"env": "Env names", "env": "Env names",
"url": "URL", "url": "URL",
"headers": "Header names", "headers": "Header names",
"kind": "Kind",
"source": "Source",
"noServers": "No MCP servers found.", "noServers": "No MCP servers found.",
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry." "notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
}, },
@@ -162,8 +141,6 @@
"rawJson": "Raw JSON" "rawJson": "Raw JSON"
}, },
"memory": { "memory": {
"userScope": "User memory",
"projectScope": "Project memory",
"missing": "No CLAUDE.md found.", "missing": "No CLAUDE.md found.",
"autoTitle": "Project memory store", "autoTitle": "Project memory store",
"autoSubtitle": "Per-project file-based memory under ~/.claude/projects/<project>/memory/ — a MEMORY.md index plus one file per remembered fact. Read-only here.", "autoSubtitle": "Per-project file-based memory under ~/.claude/projects/<project>/memory/ — a MEMORY.md index plus one file per remembered fact. Read-only here.",
@@ -241,15 +218,9 @@
"backups": { "backups": {
"title": "Backups", "title": "Backups",
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.", "empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
"viewLink": "Show backup history",
"openButton": "Backups", "openButton": "Backups",
"subtitle": "Every save / delete in the editor writes a copy here first. Backup folders live outside the directories Claude Code scans, so they don't reappear as ghosts in your CLI.", "subtitle": "Every save / delete in the editor writes a copy here first. Backup folders live outside the directories Claude Code scans, so they don't reappear as ghosts in your CLI.",
"restoreHint": "Restore by copying the backup back into place (the backup is kept):", "restoreHint": "Restore by copying the backup back into place (the backup is kept):"
"size": "Size",
"created": "Created",
"type": "Type",
"scopeLabel": "Scope",
"filterAll": "All types"
}, },
"profiles": { "profiles": {
"usageCheck": "Check quota", "usageCheck": "Check quota",
@@ -314,9 +285,7 @@
"title": "Plugins run through the `claude` CLI", "title": "Plugins run through the `claude` CLI",
"body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.", "body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.",
"install": "Install a plugin:", "install": "Install a plugin:",
"installCmd": "claude plugin install <marketplace>/<plugin>", "installCmd": "claude plugin install <marketplace>/<plugin>"
"uninstall": "Uninstall this plugin:",
"uninstallButton": "Copy uninstall command"
}, },
"mcp": { "mcp": {
"title": "MCP servers are managed by the CLI", "title": "MCP servers are managed by the CLI",
@@ -22,7 +22,6 @@
}, },
"overview": { "overview": {
"rootsTitle": "Roots", "rootsTitle": "Roots",
"codexHome": "Codex home",
"projectCodexDir": "Project .codex", "projectCodexDir": "Project .codex",
"projectRoot": "Project root", "projectRoot": "Project root",
"summary": "Quick summary", "summary": "Quick summary",
@@ -72,9 +71,6 @@
"saving": "Creating…", "saving": "Creating…",
"close": "Close" "close": "Close"
}, },
"config": {
"missing": "No config.toml found."
},
"mcp": { "mcp": {
"addTitle": "Add an MCP server", "addTitle": "Add an MCP server",
"nameLabel": "Name", "nameLabel": "Name",
-18
View File
@@ -21,14 +21,11 @@
"live": "Live", "live": "Live",
"offline": "Offline", "offline": "Offline",
"running": "running", "running": "running",
"dashboardRun": "Run",
"days": "days", "days": "days",
"hours": "hours", "hours": "hours",
"updated": "Updated ", "updated": "Updated ",
"lastUpdated": "Last updated: ", "lastUpdated": "Last updated: ",
"showMore": "Show more ({{count}} remaining)",
"plusMore": "+{{count}} more", "plusMore": "+{{count}} more",
"ofSessions": "% of sessions",
"save": "Save", "save": "Save",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
@@ -89,14 +86,8 @@
"cost": { "cost": {
"model": "Model", "model": "Model",
"cost": "Cost", "cost": "Cost",
"totalCost": "Total Cost",
"totalEstimatedCost": "Total Estimated Cost" "totalEstimatedCost": "Total Estimated Cost"
}, },
"duration": {
"h_m": "{{h}}h {{m}}m",
"m_s": "{{m}}m {{s}}s",
"s": "{{s}}s"
},
"eventDetail": { "eventDetail": {
"summary": "Summary", "summary": "Summary",
"seeDetailsBelow": "Check below for full details in {{fields}}", "seeDetailsBelow": "Check below for full details in {{fields}}",
@@ -137,8 +128,6 @@
"rawPayload": "Raw Payload", "rawPayload": "Raw Payload",
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"noPayload": "No payload captured for this event.",
"viewDetails": "View details",
"expand": "Expand event", "expand": "Expand event",
"collapse": "Collapse event" "collapse": "Collapse event"
}, },
@@ -157,12 +146,7 @@
"loadMore": "Load more", "loadMore": "Load more",
"loading": "Loading...", "loading": "Loading...",
"noResults": "No events match the current filters", "noResults": "No events match the current filters",
"grouped": "Group by tool call",
"flat": "Flat list",
"groupEventCount_one": "{{count}} event",
"groupEventCount_other": "{{count}} events",
"help": { "help": {
"toggle": "Help",
"title": "About events, filters, and grouping", "title": "About events, filters, and grouping",
"subtitle": "Unlock deeper insights: badges, lifecycles, and filter mechanics explained.", "subtitle": "Unlock deeper insights: badges, lifecycles, and filter mechanics explained.",
"valuesTitle": "What each filter accepts", "valuesTitle": "What each filter accepts",
@@ -176,10 +160,8 @@
"statusesTitle": "Status badges", "statusesTitle": "Status badges",
"statusesIntro": "Status is derived from event_type. It's what you see on each row.", "statusesIntro": "Status is derived from event_type. It's what you see on each row.",
"statusWorkingDesc": "Agent started a tool call (PreToolUse). The tool is about to run.", "statusWorkingDesc": "Agent started a tool call (PreToolUse). The tool is about to run.",
"statusConnectedDesc": "Tool call completed and returned a result (PostToolUse).",
"statusCompletedDesc": "Turn or lifecycle boundary (Stop, SubagentStop, Compaction).", "statusCompletedDesc": "Turn or lifecycle boundary (Stop, SubagentStop, Compaction).",
"statusErrorDesc": "An error was recorded (error, APIError).", "statusErrorDesc": "An error was recorded (error, APIError).",
"statusIdleDesc": "Lifecycle or metadata (Notification, TurnDuration, SessionStart, SessionEnd).",
"lifecycleTitle": "Event lifecycle", "lifecycleTitle": "Event lifecycle",
"lifecycleDesc": "A typical tool call emits two events sharing the same tool_use_id:", "lifecycleDesc": "A typical tool call emits two events sharing the same tool_use_id:",
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)", "lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
@@ -7,7 +7,6 @@
"eventsToday": "Events Today", "eventsToday": "Events Today",
"totalEvents": "Total Events", "totalEvents": "Total Events",
"totalCost": "Total Cost", "totalCost": "Total Cost",
"viewBoard": "View Board",
"viewAll": "View All", "viewAll": "View All",
"activeAgentsSection": "Active Agents", "activeAgentsSection": "Active Agents",
"recentActivity": "Recent Activity", "recentActivity": "Recent Activity",
@@ -15,7 +14,6 @@
"noAgentsDesc": "Agents will appear here when a Claude Code session is running.", "noAgentsDesc": "Agents will appear here when a Claude Code session is running.",
"noActivity": "No activity yet", "noActivity": "No activity yet",
"noActivityDesc": "Events from Claude Code sessions will stream here in real-time.", "noActivityDesc": "Events from Claude Code sessions will stream here in real-time.",
"failedConnect": "Failed to connect to server",
"failedLoad": "Failed to load data", "failedLoad": "Failed to load data",
"activeTrend": " active", "activeTrend": " active",
"totalTrend": " in active sessions", "totalTrend": " in active sessions",
+2 -11
View File
@@ -19,15 +19,6 @@
"sessionError": "Session Error", "sessionError": "Session Error",
"subagentSpawned": "Subagent Spawned" "subagentSpawned": "Subagent Spawned"
}, },
"toolLegend": { "badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment.",
"read": "Read", "adminOnly": "Only an admin can do this. Ask an admin of this AgentHub if you need it."
"write": "Write",
"edit": "Edit",
"bash": "Bash",
"grep": "Grep",
"glob": "Glob",
"agent": "Agent",
"other": "Other"
},
"badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment."
} }
-27
View File
@@ -1,45 +1,18 @@
{ {
"title": "Kanban Board", "title": "Kanban Board",
"subtitle": "Kanban view of all agents by status", "subtitle": "Kanban view of all agents by status",
"subtitleSessions": "Kanban view of all sessions by status",
"viewToggle": {
"agents": "Agents",
"sessions": "Sessions"
},
"noAgents": "No agents tracked yet", "noAgents": "No agents tracked yet",
"noAgentsDesc": "Start a Claude Code session with hooks installed to see agents appear here.", "noAgentsDesc": "Start a Claude Code session with hooks installed to see agents appear here.",
"agentCount": "{{count}} agent tracked", "agentCount": "{{count}} agent tracked",
"agentCount_plural": "{{count}} agents tracked",
"noAgentsInColumn": "No agents",
"noSessions": "No sessions tracked yet", "noSessions": "No sessions tracked yet",
"noSessionsDesc": "Start a Claude Code session with hooks installed to see sessions appear here.", "noSessionsDesc": "Start a Claude Code session with hooks installed to see sessions appear here.",
"sessionCount": "{{count}} session tracked", "sessionCount": "{{count}} session tracked",
"sessionCount_plural": "{{count}} sessions tracked",
"noSessionsInColumn": "No sessions",
"ran": "ran ", "ran": "ran ",
"running": "running ", "running": "running ",
"session": { "session": {
"anonymous": "Untitled session",
"agentSummary_one": "{{count}} agent",
"agentSummary_other": "{{count}} agents",
"subagentSummary_one": "{{count}} subagent", "subagentSummary_one": "{{count}} subagent",
"subagentSummary_other": "{{count}} subagents", "subagentSummary_other": "{{count}} subagents",
"turnSummary_one": "{{count}} turn", "turnSummary_one": "{{count}} turn",
"turnSummary_other": "{{count}} turns" "turnSummary_other": "{{count}} turns"
},
"tooltip": {
"agent": {
"working": "Agent is actively running a tool call right now.\n\nEntered on PreToolUse or UserPromptSubmit, exits on Stop (turn finished).",
"waiting": "Agent finished its turn and is blocked on the human - either Claude is waiting for the next user message or has prompted for permission to use a tool.\n\nCleared on the next PreToolUse / PostToolUse, or when the session ends.",
"completed": "Agent has fully ended for this session - typically because SessionEnd fired or, for subagents, SubagentStop was processed.\n\nNo further activity will be recorded against this agent.",
"error": "Agent recorded an error and will not continue. For main agents this comes from a Stop with stop_reason=error; for subagents, from a failed run."
},
"session": {
"active": "Session is mid-turn - Claude is currently running and the user isn't blocking anything. Brief by nature: a session moves through Active during tool calls and back to Waiting at end of turn.",
"waiting": "Session is sitting on the user. Either Claude finished its turn (most common) or it has surfaced a permission prompt and is blocked until the human responds.\n\nLook here for sessions you owe a reply to.",
"completed": "Session has fully closed - SessionEnd fired (CLI exited cleanly) or the user explicitly closed it.\n\nAll agents on this session are also completed.",
"error": "Session ended with an error - Claude returned stop_reason=error, an unrecoverable failure occurred, or an upstream API error terminated the run.",
"abandoned": "Session went stale without a clean SessionEnd. Marked abandoned after 5+ minutes of no activity (orphaned by /resume, network drop, or the CLI being killed). Distinct from Completed: we never saw a real exit signal."
}
} }
} }
+2 -24
View File
@@ -1,10 +1,5 @@
{ {
"action.clear": "clear state",
"action.forget": "Delete workspace",
"action.purge": "delete history",
"action.reset": "reset worktree", "action.reset": "reset worktree",
"action.start": "start",
"action.stop": "stop",
"actionError": "Workspace action failed: {{message}}", "actionError": "Workspace action failed: {{message}}",
"actionErrorUnknown": "Unknown error", "actionErrorUnknown": "Unknown error",
"add": "Add workspace", "add": "Add workspace",
@@ -110,14 +105,6 @@
"changes.unstage": "Unstage this file", "changes.unstage": "Unstage this file",
"changes.changesView": "Changes", "changes.changesView": "Changes",
"changes.wholeFile": "Whole file", "changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.",
"confirmRemoveTitle": "Remove workspace?",
"countDead": "dead",
"countNeedsYou": "need you",
"countRunning": "running",
"countTotal": "workspaces",
"destructive.blocked.adopted": "This is an adopted directory and cannot be changed by a worktree action.", "destructive.blocked.adopted": "This is an adopted directory and cannot be changed by a worktree action.",
"destructive.blocked.missing": "The workspace directory is missing, so this action cannot continue.", "destructive.blocked.missing": "The workspace directory is missing, so this action cannot continue.",
"destructive.blocked.unreadable": "The workspace directory cannot be read as a Git worktree, so this action cannot continue.", "destructive.blocked.unreadable": "The workspace directory cannot be read as a Git worktree, so this action cannot continue.",
@@ -189,10 +176,6 @@
"gitBar.switchHint": "Switch this worktree to another local branch", "gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…", "gitBar.switchLabel": "Switch branch…",
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.", "gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
"kind.adopted": "adopted",
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Workspace details",
"locks.held_one": "{{count}} lock held", "locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held", "locks.held_other": "{{count}} locks held",
"mode.repo": "Repo", "mode.repo": "Repo",
@@ -206,25 +189,18 @@
"preflightErrorWithMessage": "Could not load the current workspace facts: {{message}}", "preflightErrorWithMessage": "Could not load the current workspace facts: {{message}}",
"proof.ticketReport": "Task report", "proof.ticketReport": "Task report",
"renameLabel": "Rename this workspace", "renameLabel": "Rename this workspace",
"reorderHint": "Drag to reorder · Alt+←/→",
"proxy.native": "Native ({{tool}})", "proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy", "proxy.label": "AI Proxy",
"runtime.boot": "▶ up",
"runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…",
"runtime.down": "down", "runtime.down": "down",
"runtime.healthy": "healthy", "runtime.healthy": "healthy",
"runtime.partial": "partial", "runtime.partial": "partial",
"runtime.slot": "slot {{slot}}", "runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use", "runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
"status.failed": "failed", "status.failed": "failed",
"status.idle": "idle", "status.idle": "idle",
"status.provisioning": "provisioning", "status.provisioning": "provisioning",
"status.running": "running", "status.running": "running",
"title": "Workspaces", "title": "Workspaces",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"worktrees.heading_one": "{{count}} worktree", "worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees", "worktrees.heading_other": "{{count}} worktrees",
"changes.undoHunk": "Undo this hunk", "changes.undoHunk": "Undo this hunk",
@@ -386,6 +362,7 @@
"home.memoryEmpty": "Memory shows here once the folder has a CLAUDE.md or AGENTS.md, or Claude saves memory for it.", "home.memoryEmpty": "Memory shows here once the folder has a CLAUDE.md or AGENTS.md, or Claude saves memory for it.",
"home.context": "Context", "home.context": "Context",
"home.contextEmpty": "Add PDFs, documents, or other text to reference in this workspace.", "home.contextEmpty": "Add PDFs, documents, or other text to reference in this workspace.",
"home.contextSnapshot": "Each file is copied when you add it — later edits to the original are not seen. Remove and add it again, or add its folder under Folder to always read the latest.",
"home.addFile": "Add files", "home.addFile": "Add files",
"home.folder": "Folder", "home.folder": "Folder",
"home.ownFolder": "Workspace folder", "home.ownFolder": "Workspace folder",
@@ -452,6 +429,7 @@
"tree.showMore_one": "Show {{count}} more", "tree.showMore_one": "Show {{count}} more",
"tree.showMore_other": "Show {{count}} more", "tree.showMore_other": "Show {{count}} more",
"tree.seeAll": "See all ({{count}} more)", "tree.seeAll": "See all ({{count}} more)",
"tree.showLess": "Show less",
"home.contextPanel": "Workspace context", "home.contextPanel": "Workspace context",
"row.state.needsYou": "needs you", "row.state.needsYou": "needs you",
"row.state.error": "error", "row.state.error": "error",
-11
View File
@@ -1,6 +1,5 @@
{ {
"activityFeed": "Activity Feed", "activityFeed": "Activity Feed",
"agentBoard": "Kanban Board",
"agents": "Agents", "agents": "Agents",
"analytics": "Analytics", "analytics": "Analytics",
"avg": "avg", "avg": "avg",
@@ -19,20 +18,11 @@
"daysAgo": "{{count}}d ago", "daysAgo": "{{count}}d ago",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"eventsLastMin": "Last 60s", "eventsLastMin": "Last 60s",
"eventsReceived": "Events received",
"eventsTotal": "Total", "eventsTotal": "Total",
"expand": "Expand sidebar", "expand": "Expand sidebar",
"hoursAgo": "{{count}}h ago", "hoursAgo": "{{count}}h ago",
"justNow": "just now", "justNow": "just now",
"language": "Language", "language": "Language",
"languageNames": {
"en": "English",
"vi": "Vietnamese"
},
"languageShort": {
"en": "EN",
"vi": "VI"
},
"lastEvent": "Last event", "lastEvent": "Last event",
"live": "Live", "live": "Live",
"minutesAgo": "{{count}}m ago", "minutesAgo": "{{count}}m ago",
@@ -55,7 +45,6 @@
"unitPerSec": "events/s", "unitPerSec": "events/s",
"upToDate": "Up to date", "upToDate": "Up to date",
"updateAvailable": "Update available", "updateAvailable": "Update available",
"website": "Website",
"workspace": "Workspace", "workspace": "Workspace",
"wsEndpoint": "WebSocket", "wsEndpoint": "WebSocket",
"releaseAvailable": "{{version}} available", "releaseAvailable": "{{version}} available",
+8 -80
View File
@@ -1,33 +1,16 @@
{ {
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.", "emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
"actions": { "actions": {
"newSession": "New session", "newSession": "New session"
"send": "Send",
"sending": "Sending…",
"start": "Run",
"starting": "Starting…",
"stop": "Stop",
"stopping": "Stopping…"
}, },
"autocomplete": { "autocomplete": {
"fileHint": "Type @ to reference a file", "fileHint": "Type @ to reference a file",
"groupBuiltin": "Built-in (CLI only - won't run from here)",
"groupFiles": "Files in cwd",
"groupProject": "Project commands",
"groupUser": "User commands",
"noMatches": "No matches", "noMatches": "No matches",
"slashHint": "Type / for slash commands" "slashHint": "Type / for slash commands"
}, },
"binary": { "binary": {
"found": "Using {{path}}",
"missing": "The `{{cli}}` CLI isn't on your PATH. Install {{tool}} or set PATH so the dashboard can spawn it." "missing": "The `{{cli}}` CLI isn't on your PATH. Install {{tool}} or set PATH so the dashboard can spawn it."
}, },
"concurrency": {
"active_one": "{{count}} run already in flight",
"active_other": "{{count}} runs already in flight",
"atCap": "Concurrency cap reached ({{max}}). Stop one of the running runs to start a new one.",
"viewActive": "View active runs"
},
"empty": { "empty": {
"body": "Type a prompt and click Run. Output streams here as the model thinks, calls tools, and answers.", "body": "Type a prompt and click Run. Output streams here as the model thinks, calls tools, and answers.",
"title": "Nothing yet" "title": "Nothing yet"
@@ -40,28 +23,16 @@
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}", "runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
"sendFailed": "Couldn't send: {{message}}" "sendFailed": "Couldn't send: {{message}}"
}, },
"events": {
"claude": "Claude",
"collapse": "Collapse",
"expand": "Expand",
"system": "System",
"thinking": "Thinking",
"tool": "Tool",
"toolResult": "Tool result",
"you": "You"
},
"fields": { "fields": {
"approval": "Approval", "approval": "Approval",
"approvalNever": "Never", "approvalNever": "Never",
"approvalOnRequest": "On request", "approvalOnRequest": "On request",
"cwd": "Working directory",
"cwdGroups": { "cwdGroups": {
"dashboard": "Dashboard server", "dashboard": "Dashboard server",
"home": "Home", "home": "Home",
"repo": "Source repos", "repo": "Source repos",
"recent": "Recent - used by Claude Code" "recent": "Recent - used by Claude Code"
}, },
"cwdHint": "Absolute path. Defaults to the dashboard's own cwd.",
"cwdNoMatches": "No matches. Type an absolute path to use it directly.", "cwdNoMatches": "No matches. Type an absolute path to use it directly.",
"cwdPlaceholder": "Type to search or paste an absolute path…", "cwdPlaceholder": "Type to search or paste an absolute path…",
"effort": "Thinking effort", "effort": "Thinking effort",
@@ -82,64 +53,15 @@
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)", "permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode", "permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)", "permissionPlan": "plan (read-only planning)",
"prompt": "Prompt",
"promptPlaceholder": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
"sandbox": "Sandbox", "sandbox": "Sandbox",
"sandboxFullAccess": "Full access (dangerous)", "sandboxFullAccess": "Full access (dangerous)",
"sandboxReadOnly": "Read only", "sandboxReadOnly": "Read only",
"sandboxWorkspaceWrite": "Workspace write" "sandboxWorkspaceWrite": "Workspace write"
}, },
"footer": {
"cost": "Cost",
"duration": "Duration",
"exitCode": "Exit code",
"sessionId": "Session ID",
"turns": "Turns"
},
"hint": { "hint": {
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.", "permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"bypassWarning": "{{tool}} will run every command and edit every file without asking. Only for a prompt and a folder you trust.", "bypassWarning": "{{tool}} will run every command and edit every file without asking. Only for a prompt and a folder you trust.",
"sandboxWarning": "Codex will run commands with no sandbox.", "sandboxWarning": "Codex will run commands with no sandbox."
"shortcut": "Enter to send · Shift+Enter for newline"
},
"mode": {
"conversation": "Conversation",
"conversationHint": "Multi-turn - keep typing follow-ups while the agent works.",
"headless": "One-shot",
"headlessHint": "Single prompt, single response. Stdin closes after spawn.",
"label": "Mode"
},
"resume": {
"clear": "Clear selection",
"freshHint": "Start a fresh Claude Code session.",
"freshOption": "New session",
"label": "Source session",
"lastOption": "Resume last session",
"noSessions": "No sessions found.",
"originalCwd": "Original cwd",
"pickSession": "Pick a session…",
"preview": "Last activity",
"resumeHint": "Pick a session from your history and continue the conversation. Cwd is locked to the original.",
"search": "Search by ID, cwd, or status…",
"selectedBadge": "Resuming"
},
"statusHeader": {
"cwd": "Cwd",
"duration": "Elapsed",
"effort": "Effort",
"envelopes": "Events",
"model": "Model",
"permissionMode": "Mode",
"session": "Session"
},
"tokens": {
"cacheRead": "Cache hit",
"cost": "Cost",
"input": "Input",
"label": "Context",
"output": "Output"
}, },
"drop": { "drop": {
"hint": "Drop files or images to attach", "hint": "Drop files or images to attach",
@@ -159,5 +81,11 @@
"findPrev": "Previous match", "findPrev": "Previous match",
"findNext": "Next match", "findNext": "Next match",
"findClose": "Close find" "findClose": "Close find"
},
"chat": {
"placeholder": "Write a message…",
"send": "Send",
"hint": "Enter sends · permission prompts and menus need the terminal (Settings → Appearance → Chat UI)",
"notSent": "The terminal is not connected — the message was not sent."
} }
} }
+18 -3
View File
@@ -8,7 +8,6 @@
"filterError": "Error", "filterError": "Error",
"filterAbandoned": "Abandoned", "filterAbandoned": "Abandoned",
"sessionCount": "{{count}} session recorded", "sessionCount": "{{count}} session recorded",
"sessionCount_plural": "{{count}} sessions recorded",
"noSessions": "No sessions found", "noSessions": "No sessions found",
"noSessionsDesc": "Try adjusting your search or filters.", "noSessionsDesc": "Try adjusting your search or filters.",
"noSessionsHint": "Start a Claude Code session with hooks installed to begin tracking.", "noSessionsHint": "Start a Claude Code session with hooks installed to begin tracking.",
@@ -26,14 +25,12 @@
"generic": "Claude Code is paused in this session until you respond in the terminal." "generic": "Claude Code is paused in this session until you respond in the terminal."
}, },
"failedLoad": "Failed to load session", "failedLoad": "Failed to load session",
"loading": "Loading session...",
"notFound": "Session not found", "notFound": "Session not found",
"backToSessions": "Back to Sessions", "backToSessions": "Back to Sessions",
"agents": "Agents", "agents": "Agents",
"noAgents": "No agents recorded.", "noAgents": "No agents recorded.",
"unparented": "Unparented Subagents", "unparented": "Unparented Subagents",
"costBreakdown": "Cost Breakdown", "costBreakdown": "Cost Breakdown",
"eventTimeline": "Event Timeline",
"noEvents": "No events recorded.", "noEvents": "No events recorded.",
"conversation": "Conversation", "conversation": "Conversation",
"timeline": "Timeline ({{shown}}/{{total}})", "timeline": "Timeline ({{shown}}/{{total}})",
@@ -151,6 +148,8 @@
"persistedOutput": "Persisted output", "persistedOutput": "Persisted output",
"taskNotification": "Task notification", "taskNotification": "Task notification",
"thinking": "Thinking", "thinking": "Thinking",
"showMore": "Show more",
"showLess": "Show less",
"chars_one": "{{formatted}} char", "chars_one": "{{formatted}} char",
"chars_other": "{{formatted}} chars", "chars_other": "{{formatted}} chars",
"sender": { "sender": {
@@ -181,6 +180,22 @@
"statusError": "error", "statusError": "error",
"statusOk": "ok", "statusOk": "ok",
"statusPending": "pending" "statusPending": "pending"
},
"work": {
"commands_one": "ran {{count}} command",
"commands_other": "ran {{count}} commands",
"edits_one": "edited a file",
"edits_other": "made {{count}} edits",
"reads_one": "read a file",
"reads_other": "read {{count}} files",
"searches_one": "searched the code",
"searches_other": "ran {{count}} searches",
"web_one": "looked up the web",
"web_other": "made {{count}} web lookups",
"agents_one": "ran an agent",
"agents_other": "ran {{count}} agents",
"tools_one": "used a tool",
"tools_other": "used {{count}} tools"
} }
}, },
"overview": { "overview": {
+35
View File
@@ -326,6 +326,14 @@
"confirmMessageAutostart": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`, or until the next boot/login (start automatically is on).", "confirmMessageAutostart": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`, or until the next boot/login (start automatically is on).",
"done": "AgentHub is stopping. Run `agenthub start` to start it again." "done": "AgentHub is stopping. Run `agenthub start` to start it again."
}, },
"uninstall": {
"label": "Uninstall AgentHub",
"desc": "Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.",
"button": "Uninstall",
"confirmTitle": "Uninstall AgentHub?",
"confirmMessage": "Every workspace terminal is closed and AgentHub is removed for good, its database (history, workspaces, settings, saved logins) included — this cannot be undone. Conversations of Claude Code, Codex and the other AI tools, workspace worktrees and your repositories are kept.",
"done": "AgentHub is being uninstalled; this page stops answering in a moment. Log: {{log}}"
},
"about": { "about": {
"title": "System", "title": "System",
"release": "Release", "release": "Release",
@@ -670,6 +678,7 @@
"enabledOf": "{{tool}} enabled", "enabledOf": "{{tool}} enabled",
"homepage": "Homepage", "homepage": "Homepage",
"homepageOf": "{{tool}} homepage", "homepageOf": "{{tool}} homepage",
"openSettings": "Open {{tool}} settings",
"install": "Install", "install": "Install",
"update": "Update", "update": "Update",
"updateAll": "Update all ({{count}})", "updateAll": "Update all ({{count}})",
@@ -796,6 +805,8 @@
}, },
"settingsNav": { "settingsNav": {
"label": "Settings sections", "label": "Settings sections",
"toolsTab": "All tools",
"toolTabs": "AI tool settings",
"back": "Back to Workspace", "back": "Back to Workspace",
"group": { "group": {
"general": "General", "general": "General",
@@ -936,6 +947,23 @@
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.", "ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page." "envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
}, },
"backup": {
"title": "Daily backup",
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
"folder": "Backup folder",
"folderHint": "An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.",
"hour": "Time (server)",
"keep": "Backups kept",
"runNow": "Back up now",
"running": "Backing up…",
"done": "Backup written: {{file}}",
"failed": "Backup failed: {{error}}",
"newest": "Newest: {{time}}, {{size}} · {{n}} kept, {{total}} in all",
"none": "No backup in this folder yet.",
"off": "Backups are off.",
"lastFailed": "The last run ({{time}}) failed: {{error}}",
"restoreHint": "To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start."
},
"reports": { "reports": {
"title": "Scheduled reports", "title": "Scheduled reports",
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.", "description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
@@ -985,5 +1013,12 @@
"title": "Model pricing", "title": "Model pricing",
"description": "Prices for every vendor's models live on one page.", "description": "Prices for every vendor's models live on one page.",
"open": "Open Pricing" "open": "Open Pricing"
},
"chatUi": {
"title": "Chat UI",
"description": "How a Claude or Codex session opens in its workspace. Saved for your account only.",
"label": "Open sessions as a chat",
"labelDesc": "Show every Claude or Codex session's conversation like Claude Desktop, with a message box, instead of its terminal. Permission prompts and menus still need the terminal: turn this off to answer them.",
"saveFailed": "Could not save: {{message}}"
} }
} }
+5 -5
View File
@@ -14,12 +14,12 @@
"roles": { "roles": {
"admin": "Admin", "admin": "Admin",
"analyst": "Analyst", "analyst": "Analyst",
"member": "Member" "developer": "Developer"
}, },
"roleHint": { "roleHint": {
"admin": "Everything, including this server's configuration and every user's data", "admin": "Everything, including this server's configuration and every user's data",
"analyst": "Reads everything; no terminal, no changes", "analyst": "Sees every user's data; works only in their own workspaces, changes no settings",
"member": "Only their own workspaces" "developer": "Only their own workspaces; changes no settings"
}, },
"add": "Add user", "add": "Add user",
"username": "Username", "username": "Username",
@@ -48,7 +48,7 @@
"password": "Reset {{name}}'s password", "password": "Reset {{name}}'s password",
"passwordBody": "{{name}} is signed out everywhere and signs in with the new password.", "passwordBody": "{{name}} is signed out everywhere and signs in with the new password.",
"workspace": "Give {{name}} a workspace", "workspace": "Give {{name}} a workspace",
"workspaceBody": "The workspace's sessions and costs move to {{name}}. A member sees only their own workspaces." "workspaceBody": "The workspace's sessions and costs move to {{name}}. A developer sees only their own workspaces."
}, },
"done": { "done": {
"created": "{{name}} added", "created": "{{name}} added",
@@ -68,6 +68,6 @@
}, },
"readOnly": "Analysts can view accounts but not change them.", "readOnly": "Analysts can view accounts but not change them.",
"auditTitle": "Audit log", "auditTitle": "Audit log",
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a member owns.", "auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
"auditEmpty": "Nothing recorded yet." "auditEmpty": "Nothing recorded yet."
} }
@@ -2,8 +2,6 @@
"title": "Workflows", "title": "Workflows",
"subtitle": "Agent orchestration intelligence", "subtitle": "Agent orchestration intelligence",
"failedLoad": "Failed to load workflow data", "failedLoad": "Failed to load workflow data",
"filteredBy": "Filtered by:",
"clearFilter": "Clear filter",
"refreshData": "Refresh data", "refreshData": "Refresh data",
"exportJson": "Export as JSON", "exportJson": "Export as JSON",
"allSessions": "All Sessions", "allSessions": "All Sessions",
@@ -238,8 +236,6 @@
"noToolEvents": "No tool events recorded.", "noToolEvents": "No tool events recorded.",
"noEvents": "No events recorded for this session.", "noEvents": "No events recorded for this session.",
"showingOf": "Showing 100 of {{total}} events", "showingOf": "Showing 100 of {{total}} events",
"sessionInfo": " session(s) - active {{from}}%-{{to}}% of session",
"sortedByCompaction": " session(s) (sorted by compaction count)",
"openSession": "Open session", "openSession": "Open session",
"openInWorkspace": "Open in workspace" "openInWorkspace": "Open in workspace"
}, },
@@ -266,7 +262,6 @@
"subtitle": "Where errors cluster in agent hierarchy depth", "subtitle": "Where errors cluster in agent hierarchy depth",
"noErrors": "No errors recorded", "noErrors": "No errors recorded",
"allSuccess": "All sessions completed successfully", "allSuccess": "All sessions completed successfully",
"sessionsHadErrors": " sessions had errors",
"sessionsErrorSummary": "{{errorSessions}} of {{totalSessions}} sessions had errors", "sessionsErrorSummary": "{{errorSessions}} of {{totalSessions}} sessions had errors",
"agentErrors": " agent-level error(s) across hierarchy", "agentErrors": " agent-level error(s) across hierarchy",
"sessionErrorsOnly": "Session-level errors only", "sessionErrorsOnly": "Session-level errors only",
@@ -306,7 +301,6 @@
"ofTotal": "of {{total}}", "ofTotal": "of {{total}}",
"avgPerSession": "Avg / affected session", "avgPerSession": "Avg / affected session",
"peakSession": "Most in one session", "peakSession": "Most in one session",
"tokensRecovered": "Tokens Recovered",
"distribution": "Sessions by compaction count", "distribution": "Sessions by compaction count",
"xAxis": "Compactions per session", "xAxis": "Compactions per session",
"yAxis": "Sessions", "yAxis": "Sessions",
@@ -4,12 +4,9 @@
"export": "Xuất", "export": "Xuất",
"totalSessions": "Tổng số phiên", "totalSessions": "Tổng số phiên",
"totalAgents": "Tổng số Agent", "totalAgents": "Tổng số Agent",
"totalTokens": "Tổng token",
"totalCost": "Tổng chi phí", "totalCost": "Tổng chi phí",
"totalEvents": "Tổng sự kiện", "totalEvents": "Tổng sự kiện",
"cacheHitRate": "% tỷ lệ trúng bộ nhớ đệm",
"perSession": " mỗi phiên", "perSession": " mỗi phiên",
"noDataCost": "Không có dữ liệu",
"eventCountLabel_one": "{{count}} sự kiện", "eventCountLabel_one": "{{count}} sự kiện",
"eventCountLabel_other": "{{count}} sự kiện", "eventCountLabel_other": "{{count}} sự kiện",
"tabs": { "tabs": {
+5 -36
View File
@@ -30,14 +30,12 @@
}, },
"overview": { "overview": {
"rootsTitle": "Thư mục gốc", "rootsTitle": "Thư mục gốc",
"claudeHome": "Claude home",
"projectClaudeDir": "Dự án .claude", "projectClaudeDir": "Dự án .claude",
"projectRoot": "Thư mục dự án", "projectRoot": "Thư mục dự án",
"claudeJson": "claude.json", "claudeJson": "claude.json",
"summary": "Tóm tắt nhanh", "summary": "Tóm tắt nhanh",
"user": "người dùng", "user": "người dùng",
"project": "dự án", "project": "dự án"
"items": "mục"
}, },
"common": { "common": {
"search": "Tìm theo tên…", "search": "Tìm theo tên…",
@@ -47,32 +45,19 @@
"close": "Đóng", "close": "Đóng",
"copyPath": "Sao chép đường dẫn", "copyPath": "Sao chép đường dẫn",
"copyName": "Sao chép tên", "copyName": "Sao chép tên",
"copied": "Đã sao chép",
"truncated": "(đã cắt bớt để xem trước)", "truncated": "(đã cắt bớt để xem trước)",
"size": "Kích thước",
"modified": "Đã sửa",
"scopeLabel": "Phạm vi", "scopeLabel": "Phạm vi",
"noFrontmatter": "Không có frontmatter",
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ.", "redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ.",
"preview": "Xem trước", "preview": "Xem trước",
"raw": "Mã nguồn" "raw": "Mã nguồn"
}, },
"skills": {
"name": "Tên",
"description": "Mô tả"
},
"agents": { "agents": {
"name": "Tên", "name": "Tên",
"model": "Model",
"tools": "Công cụ" "tools": "Công cụ"
}, },
"plugins": { "plugins": {
"manifest": "Manifest",
"marketplace": "Marketplace",
"version": "Phiên bản",
"installedAt": "Cài đặt lúc", "installedAt": "Cài đặt lúc",
"lastUpdated": "Cập nhật", "lastUpdated": "Cập nhật",
"installPath": "Đường dẫn cài đặt",
"missing": "Thiếu đường dẫn cài đặt", "missing": "Thiếu đường dẫn cài đặt",
"manifestMissing": "Không tìm thấy installed_plugins.json tại {{path}}", "manifestMissing": "Không tìm thấy installed_plugins.json tại {{path}}",
"enabled": "Đang bật", "enabled": "Đang bật",
@@ -85,8 +70,7 @@
"hooks_other": "{{count}} hook script", "hooks_other": "{{count}} hook script",
"author": "Tác giả", "author": "Tác giả",
"homepage": "Trang chủ", "homepage": "Trang chủ",
"license": "Giấy phép", "license": "Giấy phép"
"description": "Mô tả"
}, },
"marketplaces": { "marketplaces": {
"manifest": "Manifest marketplace đã biết", "manifest": "Manifest marketplace đã biết",
@@ -95,11 +79,9 @@
"lastUpdated": "Đồng bộ lần cuối", "lastUpdated": "Đồng bộ lần cuối",
"pluginCount": "Plugin", "pluginCount": "Plugin",
"owner": "Chủ sở hữu", "owner": "Chủ sở hữu",
"installLocation": "Đường dẫn cài",
"addCmd": "claude plugin marketplace add <repo>" "addCmd": "claude plugin marketplace add <repo>"
}, },
"keybindings": { "keybindings": {
"file": "Tệp nguồn",
"missing": "Không tìm thấy keybindings.json tại {{path}}", "missing": "Không tìm thấy keybindings.json tại {{path}}",
"context": "Ngữ cảnh", "context": "Ngữ cảnh",
"key": "Phím", "key": "Phím",
@@ -123,8 +105,7 @@
}, },
"hookScripts": { "hookScripts": {
"title": "Script ~/.claude/hooks/", "title": "Script ~/.claude/hooks/",
"subtitle": "Các script hỗ trợ được tham chiếu bởi lệnh hook của bạn. Chạy khi `hooks.<event>.hooks[].command` trong settings.json gọi đến.", "subtitle": "Các script hỗ trợ được tham chiếu bởi lệnh hook của bạn. Chạy khi `hooks.<event>.hooks[].command` trong settings.json gọi đến."
"empty": "Không có script trong ~/.claude/hooks/."
}, },
"mcp": { "mcp": {
"userScope": "Máy chủ phạm vi người dùng", "userScope": "Máy chủ phạm vi người dùng",
@@ -138,8 +119,6 @@
"env": "Tên env", "env": "Tên env",
"url": "URL", "url": "URL",
"headers": "Tên Header", "headers": "Tên Header",
"kind": "Loại",
"source": "Nguồn",
"noServers": "Không tìm thấy máy chủ MCP.", "noServers": "Không tìm thấy máy chủ MCP.",
"notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này." "notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này."
}, },
@@ -157,8 +136,6 @@
"rawJson": "JSON thô" "rawJson": "JSON thô"
}, },
"memory": { "memory": {
"userScope": "Bộ nhớ người dùng",
"projectScope": "Bộ nhớ dự án",
"missing": "Không tìm thấy CLAUDE.md.", "missing": "Không tìm thấy CLAUDE.md.",
"autoTitle": "Kho bộ nhớ dự án", "autoTitle": "Kho bộ nhớ dự án",
"autoSubtitle": "Bộ nhớ theo tệp cho từng dự án trong ~/.claude/projects/<dự-án>/memory/ — một chỉ mục MEMORY.md cùng một tệp cho mỗi sự kiện được ghi nhớ. Chỉ đọc tại đây.", "autoSubtitle": "Bộ nhớ theo tệp cho từng dự án trong ~/.claude/projects/<dự-án>/memory/ — một chỉ mục MEMORY.md cùng một tệp cho mỗi sự kiện được ghi nhớ. Chỉ đọc tại đây.",
@@ -236,15 +213,9 @@
"backups": { "backups": {
"title": "Bản sao lưu", "title": "Bản sao lưu",
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.", "empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
"viewLink": "Xem lịch sử sao lưu",
"openButton": "Bản sao lưu", "openButton": "Bản sao lưu",
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí Claude Code quét, nên không xuất hiện như mục thừa trong CLI.", "subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí Claude Code quét, nên không xuất hiện như mục thừa trong CLI.",
"restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):", "restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):"
"size": "Kích thước",
"created": "Tạo lúc",
"type": "Loại",
"scopeLabel": "Phạm vi",
"filterAll": "Tất cả loại"
}, },
"profiles": { "profiles": {
"usageCheck": "Kiểm tra quota", "usageCheck": "Kiểm tra quota",
@@ -309,9 +280,7 @@
"title": "Plugin chạy qua CLI `claude`", "title": "Plugin chạy qua CLI `claude`",
"body": "Plugin là các gói có registry riêng — `installed_plugins.json` cùng đường dẫn cài trong `plugins/cache/`. Các nút ở đây chạy đúng lệnh `claude plugin …` trên máy này nên registry luôn nhất quán. Cài plugin nghĩa là chạy mã của bên thứ ba: chỉ thêm marketplace mà bạn tin tưởng.", "body": "Plugin là các gói có registry riêng — `installed_plugins.json` cùng đường dẫn cài trong `plugins/cache/`. Các nút ở đây chạy đúng lệnh `claude plugin …` trên máy này nên registry luôn nhất quán. Cài plugin nghĩa là chạy mã của bên thứ ba: chỉ thêm marketplace mà bạn tin tưởng.",
"install": "Cài plugin:", "install": "Cài plugin:",
"installCmd": "claude plugin install <marketplace>/<plugin>", "installCmd": "claude plugin install <marketplace>/<plugin>"
"uninstall": "Gỡ plugin này:",
"uninstallButton": "Sao chép lệnh gỡ"
}, },
"mcp": { "mcp": {
"title": "MCP server được CLI quản lý", "title": "MCP server được CLI quản lý",
@@ -22,7 +22,6 @@
}, },
"overview": { "overview": {
"rootsTitle": "Thư mục gốc", "rootsTitle": "Thư mục gốc",
"codexHome": "Thư mục Codex",
"projectCodexDir": "Thư mục .codex của dự án", "projectCodexDir": "Thư mục .codex của dự án",
"projectRoot": "Thư mục gốc dự án", "projectRoot": "Thư mục gốc dự án",
"summary": "Tóm tắt nhanh", "summary": "Tóm tắt nhanh",
@@ -72,9 +71,6 @@
"saving": "Đang tạo…", "saving": "Đang tạo…",
"close": "Đóng" "close": "Đóng"
}, },
"config": {
"missing": "Không tìm thấy config.toml."
},
"mcp": { "mcp": {
"addTitle": "Thêm máy chủ MCP", "addTitle": "Thêm máy chủ MCP",
"nameLabel": "Tên", "nameLabel": "Tên",
-18
View File
@@ -21,14 +21,11 @@
"live": "Trực tiếp", "live": "Trực tiếp",
"offline": "Ngoại tuyến", "offline": "Ngoại tuyến",
"running": "đang chạy", "running": "đang chạy",
"dashboardRun": "Chạy",
"days": "ngày", "days": "ngày",
"hours": "giờ", "hours": "giờ",
"updated": "Đã cập nhật ", "updated": "Đã cập nhật ",
"lastUpdated": "Cập nhật lần cuối: ", "lastUpdated": "Cập nhật lần cuối: ",
"showMore": "Hiển thị thêm (còn {{count}})",
"plusMore": "+{{count}} nữa", "plusMore": "+{{count}} nữa",
"ofSessions": "% số phiên",
"save": "Lưu", "save": "Lưu",
"edit": "Sửa", "edit": "Sửa",
"delete": "Xóa", "delete": "Xóa",
@@ -89,14 +86,8 @@
"cost": { "cost": {
"model": "Mô hình", "model": "Mô hình",
"cost": "Chi phí", "cost": "Chi phí",
"totalCost": "Tổng chi phí",
"totalEstimatedCost": "Tổng chi phí ước tính" "totalEstimatedCost": "Tổng chi phí ước tính"
}, },
"duration": {
"h_m": "{{h}}h {{m}}m",
"m_s": "{{m}}m {{s}}s",
"s": "{{s}}s"
},
"eventDetail": { "eventDetail": {
"summary": "Tóm tắt", "summary": "Tóm tắt",
"seeDetailsBelow": "Xem chi tiết đầy đủ bên dưới trong {{fields}}", "seeDetailsBelow": "Xem chi tiết đầy đủ bên dưới trong {{fields}}",
@@ -137,8 +128,6 @@
"rawPayload": "Dữ Liệu Thô", "rawPayload": "Dữ Liệu Thô",
"copy": "Sao chép", "copy": "Sao chép",
"copied": "Đã sao chép", "copied": "Đã sao chép",
"noPayload": "Không có dữ liệu cho sự kiện này.",
"viewDetails": "Xem chi tiết",
"expand": "Mở rộng sự kiện", "expand": "Mở rộng sự kiện",
"collapse": "Thu gọn sự kiện" "collapse": "Thu gọn sự kiện"
}, },
@@ -157,12 +146,7 @@
"loadMore": "Tải thêm", "loadMore": "Tải thêm",
"loading": "Đang tải...", "loading": "Đang tải...",
"noResults": "Không có sự kiện nào khớp với bộ lọc", "noResults": "Không có sự kiện nào khớp với bộ lọc",
"grouped": "Nhóm theo lần gọi công cụ",
"flat": "Danh sách phẳng",
"groupEventCount_one": "{{count}} sự kiện",
"groupEventCount_other": "{{count}} sự kiện",
"help": { "help": {
"toggle": "Trợ giúp",
"title": "Về sự kiện, bộ lọc và nhóm", "title": "Về sự kiện, bộ lọc và nhóm",
"subtitle": "Nhấn để mở - trạng thái, vòng đời, ngữ nghĩa bộ lọc, giá trị bộ lọc", "subtitle": "Nhấn để mở - trạng thái, vòng đời, ngữ nghĩa bộ lọc, giá trị bộ lọc",
"valuesTitle": "Mỗi bộ lọc chấp nhận gì", "valuesTitle": "Mỗi bộ lọc chấp nhận gì",
@@ -176,10 +160,8 @@
"statusesTitle": "Trạng thái", "statusesTitle": "Trạng thái",
"statusesIntro": "Trạng thái được suy ra từ event_type.", "statusesIntro": "Trạng thái được suy ra từ event_type.",
"statusWorkingDesc": "Agent bắt đầu gọi công cụ (PreToolUse).", "statusWorkingDesc": "Agent bắt đầu gọi công cụ (PreToolUse).",
"statusConnectedDesc": "Công cụ hoàn tất và trả kết quả (PostToolUse).",
"statusCompletedDesc": "Mốc vòng đời (Stop, SubagentStop, Compaction).", "statusCompletedDesc": "Mốc vòng đời (Stop, SubagentStop, Compaction).",
"statusErrorDesc": "Sự kiện lỗi (error, APIError).", "statusErrorDesc": "Sự kiện lỗi (error, APIError).",
"statusIdleDesc": "Sự kiện vòng đời / siêu dữ liệu (Notification, TurnDuration, SessionStart, SessionEnd).",
"lifecycleTitle": "Vòng đời sự kiện", "lifecycleTitle": "Vòng đời sự kiện",
"lifecycleDesc": "Một lần gọi công cụ thường phát ra hai sự kiện chia sẻ tool_use_id:", "lifecycleDesc": "Một lần gọi công cụ thường phát ra hai sự kiện chia sẻ tool_use_id:",
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)", "lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
@@ -7,7 +7,6 @@
"eventsToday": "Sự kiện hôm nay", "eventsToday": "Sự kiện hôm nay",
"totalEvents": "Tổng sự kiện", "totalEvents": "Tổng sự kiện",
"totalCost": "Tổng chi phí", "totalCost": "Tổng chi phí",
"viewBoard": "Xem bảng",
"viewAll": "Xem tất cả", "viewAll": "Xem tất cả",
"activeAgentsSection": "Agent đang hoạt động", "activeAgentsSection": "Agent đang hoạt động",
"recentActivity": "Hoạt động gần đây", "recentActivity": "Hoạt động gần đây",
@@ -15,7 +14,6 @@
"noAgentsDesc": "Agent sẽ xuất hiện ở đây khi phiên Claude Code đang chạy.", "noAgentsDesc": "Agent sẽ xuất hiện ở đây khi phiên Claude Code đang chạy.",
"noActivity": "Chưa có hoạt động", "noActivity": "Chưa có hoạt động",
"noActivityDesc": "Sự kiện từ các phiên Claude Code sẽ được phát trực tiếp tại đây.", "noActivityDesc": "Sự kiện từ các phiên Claude Code sẽ được phát trực tiếp tại đây.",
"failedConnect": "Không thể kết nối máy chủ",
"failedLoad": "Không thể tải dữ liệu", "failedLoad": "Không thể tải dữ liệu",
"activeTrend": " đang hoạt động", "activeTrend": " đang hoạt động",
"totalTrend": " trong phiên hoạt động", "totalTrend": " trong phiên hoạt động",
+2 -11
View File
@@ -19,15 +19,6 @@
"sessionError": "Lỗi phiên", "sessionError": "Lỗi phiên",
"subagentSpawned": "Đã tạo Subagent" "subagentSpawned": "Đã tạo Subagent"
}, },
"toolLegend": { "badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát.",
"read": "Đọc", "adminOnly": "Chỉ admin mới làm được thao tác này. Hãy nhờ admin của AgentHub nếu bạn cần."
"write": "Ghi",
"edit": "Sửa",
"bash": "Bash",
"grep": "Grep",
"glob": "Glob",
"agent": "Agent",
"other": "Khác"
},
"badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát."
} }
-27
View File
@@ -1,45 +1,18 @@
{ {
"title": "Bảng Kanban", "title": "Bảng Kanban",
"subtitle": "Góc nhìn kanban của mọi Agent theo trạng thái", "subtitle": "Góc nhìn kanban của mọi Agent theo trạng thái",
"subtitleSessions": "Góc nhìn kanban của mọi phiên theo trạng thái",
"viewToggle": {
"agents": "Agent",
"sessions": "Phiên"
},
"noAgents": "Chưa có Agent được theo dõi", "noAgents": "Chưa có Agent được theo dõi",
"noAgentsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy Agent xuất hiện tại đây.", "noAgentsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy Agent xuất hiện tại đây.",
"agentCount": "Đã theo dõi {{count}} Agent", "agentCount": "Đã theo dõi {{count}} Agent",
"agentCount_plural": "Đã theo dõi {{count}} Agent",
"noAgentsInColumn": "Không có Agent",
"noSessions": "Chưa có phiên được theo dõi", "noSessions": "Chưa có phiên được theo dõi",
"noSessionsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy phiên xuất hiện tại đây.", "noSessionsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy phiên xuất hiện tại đây.",
"sessionCount": "Đã theo dõi {{count}} phiên", "sessionCount": "Đã theo dõi {{count}} phiên",
"sessionCount_plural": "Đã theo dõi {{count}} phiên",
"noSessionsInColumn": "Không có phiên",
"ran": "đã chạy ", "ran": "đã chạy ",
"running": "đang chạy ", "running": "đang chạy ",
"session": { "session": {
"anonymous": "Phiên chưa đặt tên",
"agentSummary_one": "{{count}} Agent",
"agentSummary_other": "{{count}} Agent",
"subagentSummary_one": "{{count}} Agent con", "subagentSummary_one": "{{count}} Agent con",
"subagentSummary_other": "{{count}} Agent con", "subagentSummary_other": "{{count}} Agent con",
"turnSummary_one": "{{count}} lượt", "turnSummary_one": "{{count}} lượt",
"turnSummary_other": "{{count}} lượt" "turnSummary_other": "{{count}} lượt"
},
"tooltip": {
"agent": {
"working": "Agent đang chạy một tool call ngay lúc này.\n\nVào trạng thái khi PreToolUse hoặc UserPromptSubmit, thoát khi Stop (hết lượt).",
"waiting": "Agent đã kết thúc lượt và đang chờ người dùng - Claude chờ tin nhắn tiếp theo hoặc đã hỏi xin quyền sử dụng tool.\n\nXóa khi PreToolUse / PostToolUse tiếp theo, hoặc khi phiên kết thúc.",
"completed": "Agent đã kết thúc hoàn toàn cho phiên này - thường do SessionEnd kích hoạt, hoặc với subagent là do SubagentStop.\n\nKhông còn hoạt động nào được ghi nhận cho Agent này.",
"error": "Agent gặp lỗi và sẽ không tiếp tục. Với main agent là từ Stop với stop_reason=error; với subagent là do chạy thất bại."
},
"session": {
"active": "Phiên đang giữa lượt - Claude đang chạy và người dùng không bị chặn. Trạng thái này thoáng qua: phiên đi qua Active trong các tool call rồi quay lại Waiting khi hết lượt.",
"waiting": "Phiên đang chờ người dùng. Có thể Claude đã hết lượt (phổ biến nhất) hoặc đang hỏi quyền và bị chặn cho tới khi người dùng phản hồi.\n\nXem cột này để biết phiên nào bạn cần trả lời.",
"completed": "Phiên đã đóng hoàn toàn - SessionEnd đã kích hoạt (CLI thoát sạch) hoặc người dùng đã đóng thủ công.\n\nMọi Agent của phiên cũng đã hoàn tất.",
"error": "Phiên kết thúc với lỗi - Claude trả về stop_reason=error, có lỗi không thể khôi phục, hoặc lỗi API thượng nguồn đã chấm dứt lần chạy.",
"abandoned": "Phiên đã chết mà không có SessionEnd sạch. Bị đánh dấu bỏ dở sau 5+ phút không hoạt động (mồ côi vì /resume, mất mạng, hoặc CLI bị tắt). Khác với Completed: ta không thấy tín hiệu thoát thật sự."
}
} }
} }
+2 -24
View File
@@ -1,10 +1,5 @@
{ {
"action.clear": "Dọn trạng thái",
"action.forget": "Xóa workspace",
"action.purge": "Xóa lịch sử",
"action.reset": "Đặt lại worktree", "action.reset": "Đặt lại worktree",
"action.start": "Bắt đầu",
"action.stop": "Dừng",
"actionError": "Thao tác workspace thất bại: {{message}}", "actionError": "Thao tác workspace thất bại: {{message}}",
"actionErrorUnknown": "Lỗi không xác định", "actionErrorUnknown": "Lỗi không xác định",
"add": "Thêm workspace", "add": "Thêm workspace",
@@ -110,14 +105,6 @@
"changes.unstage": "Bỏ file này khỏi index", "changes.unstage": "Bỏ file này khỏi index",
"changes.changesView": "Thay đổi", "changes.changesView": "Thay đổi",
"changes.wholeFile": "Toàn bộ file", "changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
"confirmRemoveTitle": "Xóa workspace?",
"countDead": "đã chết",
"countNeedsYou": "cần bạn",
"countRunning": "đang chạy",
"countTotal": "workspace",
"destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác worktree.", "destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác worktree.",
"destructive.blocked.missing": "Thư mục workspace không còn, nên không thể tiếp tục thao tác này.", "destructive.blocked.missing": "Thư mục workspace không còn, nên không thể tiếp tục thao tác này.",
"destructive.blocked.unreadable": "Không thể đọc thư mục workspace như một Git worktree, nên không thể tiếp tục thao tác này.", "destructive.blocked.unreadable": "Không thể đọc thư mục workspace như một Git worktree, nên không thể tiếp tục thao tác này.",
@@ -189,10 +176,6 @@
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác", "gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…", "gitBar.switchLabel": "Đổi nhánh…",
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.", "gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
"kind.adopted": "đã nhận",
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết workspace",
"locks.held_one": "Đang giữ {{count}} khóa", "locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa",
"mode.repo": "Repo", "mode.repo": "Repo",
@@ -206,25 +189,18 @@
"preflightErrorWithMessage": "Không thể tải trạng thái workspace hiện tại: {{message}}", "preflightErrorWithMessage": "Không thể tải trạng thái workspace hiện tại: {{message}}",
"proof.ticketReport": "Báo cáo nhiệm vụ", "proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên workspace này", "renameLabel": "Đổi tên workspace này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"proxy.native": "Native ({{tool}})", "proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy", "proxy.label": "AI Proxy",
"runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…",
"runtime.down": "tắt", "runtime.down": "tắt",
"runtime.healthy": "khoẻ", "runtime.healthy": "khoẻ",
"runtime.partial": "một phần", "runtime.partial": "một phần",
"runtime.slot": "slot {{slot}}", "runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận", "runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của workspace này (khác với phiên Claude của nó)",
"status.failed": "thất bại", "status.failed": "thất bại",
"status.idle": "rảnh", "status.idle": "rảnh",
"status.provisioning": "đang khởi tạo", "status.provisioning": "đang khởi tạo",
"status.running": "đang chạy", "status.running": "đang chạy",
"title": "Workspace", "title": "Workspace",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"worktrees.heading_one": "{{count}} worktree", "worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree", "worktrees.heading_other": "{{count}} worktree",
"changes.undoHunk": "Hoàn tác đoạn này", "changes.undoHunk": "Hoàn tác đoạn này",
@@ -386,6 +362,7 @@
"home.memoryEmpty": "Bộ nhớ hiện ở đây khi thư mục có CLAUDE.md hoặc AGENTS.md, hoặc Claude lưu bộ nhớ cho nó.", "home.memoryEmpty": "Bộ nhớ hiện ở đây khi thư mục có CLAUDE.md hoặc AGENTS.md, hoặc Claude lưu bộ nhớ cho nó.",
"home.context": "Ngữ cảnh", "home.context": "Ngữ cảnh",
"home.contextEmpty": "Thêm PDF, tài liệu hoặc văn bản để tham chiếu trong workspace này.", "home.contextEmpty": "Thêm PDF, tài liệu hoặc văn bản để tham chiếu trong workspace này.",
"home.contextSnapshot": "Mỗi file được sao chép lúc thêm — sửa file gốc sau đó sẽ không cập nhật theo. Hãy xóa rồi thêm lại, hoặc thêm thư mục chứa nó ở mục Thư mục để luôn đọc bản mới nhất.",
"home.addFile": "Thêm file", "home.addFile": "Thêm file",
"home.folder": "Thư mục", "home.folder": "Thư mục",
"home.ownFolder": "Thư mục của workspace", "home.ownFolder": "Thư mục của workspace",
@@ -452,6 +429,7 @@
"tree.showMore_one": "Hiện thêm {{count}}", "tree.showMore_one": "Hiện thêm {{count}}",
"tree.showMore_other": "Hiện thêm {{count}}", "tree.showMore_other": "Hiện thêm {{count}}",
"tree.seeAll": "Xem tất cả (thêm {{count}})", "tree.seeAll": "Xem tất cả (thêm {{count}})",
"tree.showLess": "Thu gọn",
"home.contextPanel": "Ngữ cảnh workspace", "home.contextPanel": "Ngữ cảnh workspace",
"row.state.needsYou": "cần bạn", "row.state.needsYou": "cần bạn",
"row.state.error": "lỗi", "row.state.error": "lỗi",
-11
View File
@@ -1,6 +1,5 @@
{ {
"activityFeed": "Luồng hoạt động", "activityFeed": "Luồng hoạt động",
"agentBoard": "Bảng Kanban",
"agents": "Agent", "agents": "Agent",
"analytics": "Phân tích", "analytics": "Phân tích",
"avg": "TB", "avg": "TB",
@@ -19,20 +18,11 @@
"daysAgo": "{{count}} ngày trước", "daysAgo": "{{count}} ngày trước",
"disconnected": "Mất kết nối", "disconnected": "Mất kết nối",
"eventsLastMin": "60s qua", "eventsLastMin": "60s qua",
"eventsReceived": "Sự kiện đã nhận",
"eventsTotal": "Tổng", "eventsTotal": "Tổng",
"expand": "Mở rộng thanh bên", "expand": "Mở rộng thanh bên",
"hoursAgo": "{{count}} giờ trước", "hoursAgo": "{{count}} giờ trước",
"justNow": "vừa xong", "justNow": "vừa xong",
"language": "Ngôn ngữ", "language": "Ngôn ngữ",
"languageNames": {
"en": "English",
"vi": "Tiếng Việt"
},
"languageShort": {
"en": "EN",
"vi": "VI"
},
"lastEvent": "Sự kiện gần nhất", "lastEvent": "Sự kiện gần nhất",
"live": "Trực tiếp", "live": "Trực tiếp",
"minutesAgo": "{{count}} phút trước", "minutesAgo": "{{count}} phút trước",
@@ -55,7 +45,6 @@
"unitPerSec": "sự kiện/s", "unitPerSec": "sự kiện/s",
"upToDate": "Đã là mới nhất", "upToDate": "Đã là mới nhất",
"updateAvailable": "Có bản cập nhật", "updateAvailable": "Có bản cập nhật",
"website": "Website",
"workspace": "Không gian làm việc", "workspace": "Không gian làm việc",
"wsEndpoint": "WebSocket", "wsEndpoint": "WebSocket",
"releaseAvailable": "Có {{version}}", "releaseAvailable": "Có {{version}}",
+8 -79
View File
@@ -1,32 +1,16 @@
{ {
"emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.", "emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.",
"actions": { "actions": {
"newSession": "Phiên mới", "newSession": "Phiên mới"
"send": "Gửi",
"sending": "Đang gửi…",
"start": "Chạy",
"starting": "Đang khởi động…",
"stop": "Dừng",
"stopping": "Đang dừng…"
}, },
"autocomplete": { "autocomplete": {
"fileHint": "Gõ @ để tham chiếu file", "fileHint": "Gõ @ để tham chiếu file",
"groupBuiltin": "Built-in (chỉ CLI - không chạy ở đây)",
"groupFiles": "File trong cwd",
"groupProject": "Lệnh project",
"groupUser": "Lệnh user",
"noMatches": "Không có", "noMatches": "Không có",
"slashHint": "Gõ / để tìm lệnh slash" "slashHint": "Gõ / để tìm lệnh slash"
}, },
"binary": { "binary": {
"found": "Đang dùng {{path}}",
"missing": "Không thấy CLI `{{cli}}` trong PATH. Cài đặt {{tool}} hoặc đặt PATH." "missing": "Không thấy CLI `{{cli}}` trong PATH. Cài đặt {{tool}} hoặc đặt PATH."
}, },
"concurrency": {
"active_other": "Có {{count}} run đang chạy",
"atCap": "Đạt giới hạn concurrent ({{max}}). Dừng một run đang chạy để bắt đầu run mới.",
"viewActive": "Xem các run đang chạy"
},
"empty": { "empty": {
"body": "Gõ prompt và bấm Chạy. Output sẽ stream khi model suy nghĩ, gọi tool, và trả lời.", "body": "Gõ prompt và bấm Chạy. Output sẽ stream khi model suy nghĩ, gọi tool, và trả lời.",
"title": "Chưa có gì" "title": "Chưa có gì"
@@ -39,28 +23,16 @@
"runStartedButNotAttached": "Run đã khởi động nhưng không thể gắn vào bảng điều khiển: {{message}}", "runStartedButNotAttached": "Run đã khởi động nhưng không thể gắn vào bảng điều khiển: {{message}}",
"sendFailed": "Không gửi được: {{message}}" "sendFailed": "Không gửi được: {{message}}"
}, },
"events": {
"claude": "Claude",
"collapse": "Thu gọn",
"expand": "Mở rộng",
"system": "Hệ thống",
"thinking": "Đang suy nghĩ",
"tool": "Tool",
"toolResult": "Kết quả tool",
"you": "Bạn"
},
"fields": { "fields": {
"approval": "Phê duyệt", "approval": "Phê duyệt",
"approvalNever": "Không bao giờ", "approvalNever": "Không bao giờ",
"approvalOnRequest": "Khi cần", "approvalOnRequest": "Khi cần",
"cwd": "Thư mục làm việc",
"cwdGroups": { "cwdGroups": {
"dashboard": "Máy chủ dashboard", "dashboard": "Máy chủ dashboard",
"home": "Home", "home": "Home",
"repo": "Repo nguồn", "repo": "Repo nguồn",
"recent": "Gần đây - Claude Code đã dùng" "recent": "Gần đây - Claude Code đã dùng"
}, },
"cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.",
"cwdNoMatches": "Không khớp. Gõ đường dẫn tuyệt đối để dùng trực tiếp.", "cwdNoMatches": "Không khớp. Gõ đường dẫn tuyệt đối để dùng trực tiếp.",
"cwdPlaceholder": "Gõ để tìm hoặc dán đường dẫn tuyệt đối…", "cwdPlaceholder": "Gõ để tìm hoặc dán đường dẫn tuyệt đối…",
"effort": "Mức độ suy nghĩ", "effort": "Mức độ suy nghĩ",
@@ -81,64 +53,15 @@
"permissionDefault": "default (prompt tương tác - headless sẽ treo)", "permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Chế độ quyền", "permissionMode": "Chế độ quyền",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)", "permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi {{tool}} bất cứ điều gì…",
"sandbox": "Sandbox", "sandbox": "Sandbox",
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)", "sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
"sandboxReadOnly": "Chỉ đọc", "sandboxReadOnly": "Chỉ đọc",
"sandboxWorkspaceWrite": "Ghi vào workspace" "sandboxWorkspaceWrite": "Ghi vào workspace"
}, },
"footer": {
"cost": "Chi phí",
"duration": "Thời lượng",
"exitCode": "Mã thoát",
"sessionId": "Session ID",
"turns": "Lượt"
},
"hint": { "hint": {
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.", "permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"bypassWarning": "{{tool}} sẽ chạy mọi lệnh và sửa mọi file mà không hỏi. Chỉ dùng với prompt và thư mục bạn tin tưởng.", "bypassWarning": "{{tool}} sẽ chạy mọi lệnh và sửa mọi file mà không hỏi. Chỉ dùng với prompt và thư mục bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.", "sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox."
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
},
"mode": {
"conversation": "Hội thoại",
"conversationHint": "Đa lượt - tiếp tục gõ trong khi agent xử lý.",
"headless": "Một lần",
"headlessHint": "Một prompt, một phản hồi. Stdin đóng ngay sau khi spawn.",
"label": "Chế độ"
},
"resume": {
"clear": "Xoá lựa chọn",
"freshHint": "Bắt đầu phiên Claude Code mới.",
"freshOption": "Phiên mới",
"label": "Phiên nguồn",
"lastOption": "Tiếp tục phiên trước",
"noSessions": "Không tìm thấy phiên.",
"originalCwd": "cwd gốc",
"pickSession": "Chọn phiên…",
"preview": "Hoạt động gần nhất",
"resumeHint": "Chọn một phiên từ lịch sử để tiếp tục. cwd được khoá theo phiên gốc.",
"search": "Tìm theo ID, cwd hoặc status…",
"selectedBadge": "Đang tiếp tục"
},
"statusHeader": {
"cwd": "Cwd",
"duration": "Đã chạy",
"effort": "Effort",
"envelopes": "Sự kiện",
"model": "Model",
"permissionMode": "Mode",
"session": "Session"
},
"tokens": {
"cacheRead": "Trúng cache",
"cost": "Chi phí",
"input": "Input",
"label": "Context",
"output": "Output"
}, },
"drop": { "drop": {
"hint": "Thả file hoặc ảnh để đính kèm", "hint": "Thả file hoặc ảnh để đính kèm",
@@ -158,5 +81,11 @@
"findPrev": "Kết quả trước", "findPrev": "Kết quả trước",
"findNext": "Kết quả sau", "findNext": "Kết quả sau",
"findClose": "Đóng tìm kiếm" "findClose": "Đóng tìm kiếm"
},
"chat": {
"placeholder": "Viết tin nhắn…",
"send": "Gửi",
"hint": "Enter để gửi · hỏi quyền và menu cần terminal (Settings → Appearance → Giao diện chat)",
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi."
} }
} }
+18 -3
View File
@@ -8,7 +8,6 @@
"filterError": "Lỗi", "filterError": "Lỗi",
"filterAbandoned": "Bị bỏ dở", "filterAbandoned": "Bị bỏ dở",
"sessionCount": "Đã ghi nhận {{count}} phiên", "sessionCount": "Đã ghi nhận {{count}} phiên",
"sessionCount_plural": "Đã ghi nhận {{count}} phiên",
"noSessions": "Không tìm thấy phiên", "noSessions": "Không tìm thấy phiên",
"noSessionsDesc": "Hãy thử điều chỉnh tìm kiếm hoặc bộ lọc.", "noSessionsDesc": "Hãy thử điều chỉnh tìm kiếm hoặc bộ lọc.",
"noSessionsHint": "Bắt đầu một phiên Claude Code với hooks đã cài để theo dõi.", "noSessionsHint": "Bắt đầu một phiên Claude Code với hooks đã cài để theo dõi.",
@@ -26,14 +25,12 @@
"generic": "Claude Code đang tạm dừng trong phiên này cho đến khi bạn phản hồi trong terminal." "generic": "Claude Code đang tạm dừng trong phiên này cho đến khi bạn phản hồi trong terminal."
}, },
"failedLoad": "Không thể tải phiên", "failedLoad": "Không thể tải phiên",
"loading": "Đang tải phiên...",
"notFound": "Không tìm thấy phiên", "notFound": "Không tìm thấy phiên",
"backToSessions": "Quay lại danh sách phiên", "backToSessions": "Quay lại danh sách phiên",
"agents": "Agent", "agents": "Agent",
"noAgents": "Chưa có Agent được ghi nhận.", "noAgents": "Chưa có Agent được ghi nhận.",
"unparented": "Subagent không có cha", "unparented": "Subagent không có cha",
"costBreakdown": "Phân rã chi phí", "costBreakdown": "Phân rã chi phí",
"eventTimeline": "Dòng thời gian sự kiện",
"noEvents": "Chưa có sự kiện được ghi nhận.", "noEvents": "Chưa có sự kiện được ghi nhận.",
"conversation": "Hội thoại", "conversation": "Hội thoại",
"timeline": "Dòng thời gian ({{shown}}/{{total}})", "timeline": "Dòng thời gian ({{shown}}/{{total}})",
@@ -151,6 +148,8 @@
"persistedOutput": "Đầu ra đã lưu", "persistedOutput": "Đầu ra đã lưu",
"taskNotification": "Thông báo tác vụ", "taskNotification": "Thông báo tác vụ",
"thinking": "Suy nghĩ", "thinking": "Suy nghĩ",
"showMore": "Xem thêm",
"showLess": "Thu gọn",
"chars_one": "{{formatted}} ký tự", "chars_one": "{{formatted}} ký tự",
"chars_other": "{{formatted}} ký tự", "chars_other": "{{formatted}} ký tự",
"sender": { "sender": {
@@ -181,6 +180,22 @@
"statusError": "lỗi", "statusError": "lỗi",
"statusOk": "ok", "statusOk": "ok",
"statusPending": "đang chờ" "statusPending": "đang chờ"
},
"work": {
"commands_one": "đã chạy {{count}} lệnh",
"commands_other": "đã chạy {{count}} lệnh",
"edits_one": "sửa 1 file",
"edits_other": "sửa {{count}} lần",
"reads_one": "đọc 1 file",
"reads_other": "đọc {{count}} file",
"searches_one": "tìm trong code",
"searches_other": "tìm {{count}} lần",
"web_one": "tra cứu web",
"web_other": "tra cứu web {{count}} lần",
"agents_one": "chạy 1 agent",
"agents_other": "chạy {{count}} agent",
"tools_one": "dùng 1 công cụ",
"tools_other": "dùng {{count}} công cụ"
} }
}, },
"overview": { "overview": {
+35
View File
@@ -326,6 +326,14 @@
"confirmMessageAutostart": "Mọi terminal của các workspace sẽ bị đóng — agent đang chạy sẽ dừng — và dashboard tắt. Nó giữ tắt cho tới khi bạn chạy `agenthub start`, hoặc tới lần khởi động/đăng nhập máy kế tiếp (đang bật tự khởi động).", "confirmMessageAutostart": "Mọi terminal của các workspace sẽ bị đóng — agent đang chạy sẽ dừng — và dashboard tắt. Nó giữ tắt cho tới khi bạn chạy `agenthub start`, hoặc tới lần khởi động/đăng nhập máy kế tiếp (đang bật tự khởi động).",
"done": "AgentHub đang tắt. Chạy `agenthub start` để bật lại." "done": "AgentHub đang tắt. Chạy `agenthub start` để bật lại."
}, },
"uninstall": {
"label": "Gỡ cài đặt AgentHub",
"desc": "Gỡ AgentHub khỏi máy: dashboard, database và cài đặt của nó, các hook trong Claude Code, Codex và Antigravity, và plugin. Các cuộc trò chuyện, thư mục workspace và repository của bạn được giữ lại.",
"button": "Gỡ cài đặt",
"confirmTitle": "Gỡ cài đặt AgentHub?",
"confirmMessage": "Mọi terminal của các workspace sẽ bị đóng và AgentHub bị gỡ hẳn, kể cả database (lịch sử, workspace, cài đặt, đăng nhập đã lưu) — không thể hoàn tác. Các cuộc trò chuyện của Claude Code, Codex và các công cụ AI khác, worktree của workspace và repository của bạn được giữ lại.",
"done": "AgentHub đang được gỡ; trang này sẽ ngừng phản hồi trong giây lát. Log: {{log}}"
},
"about": { "about": {
"title": "Hệ thống", "title": "Hệ thống",
"release": "Phiên bản", "release": "Phiên bản",
@@ -670,6 +678,7 @@
"enabledOf": "Bật {{tool}}", "enabledOf": "Bật {{tool}}",
"homepage": "Trang chủ", "homepage": "Trang chủ",
"homepageOf": "Trang chủ {{tool}}", "homepageOf": "Trang chủ {{tool}}",
"openSettings": "Mở cài đặt {{tool}}",
"install": "Cài đặt", "install": "Cài đặt",
"update": "Cập nhật", "update": "Cập nhật",
"updateAll": "Cập nhật tất cả ({{count}})", "updateAll": "Cập nhật tất cả ({{count}})",
@@ -796,6 +805,8 @@
}, },
"settingsNav": { "settingsNav": {
"label": "Các mục cài đặt", "label": "Các mục cài đặt",
"toolsTab": "Tất cả",
"toolTabs": "Cài đặt AI tool",
"back": "Quay lại Không gian làm việc", "back": "Quay lại Không gian làm việc",
"group": { "group": {
"general": "Chung", "general": "Chung",
@@ -936,6 +947,23 @@
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.", "ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này." "envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
}, },
"backup": {
"title": "Backup hằng ngày",
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
"folder": "Thư mục backup",
"folderHint": "Đường dẫn tuyệt đối tới một thư mục có sẵn mà server ghi được. Để trống để tắt backup. AgentHub chỉ xóa đúng các tệp agenthub-YYYYMMDD-HHMMSS.db của chính nó ở đây, bản cũ nhất trước khi vượt số bản giữ lại.",
"hour": "Giờ chạy (giờ server)",
"keep": "Số bản giữ lại",
"runNow": "Backup ngay",
"running": "Đang backup…",
"done": "Đã ghi bản backup: {{file}}",
"failed": "Backup thất bại: {{error}}",
"newest": "Mới nhất: {{time}}, {{size}} · đang giữ {{n}} bản, tổng {{total}}",
"none": "Thư mục này chưa có bản backup nào.",
"off": "Backup đang tắt.",
"lastFailed": "Lần chạy gần nhất ({{time}}) thất bại: {{error}}",
"restoreHint": "Khôi phục: agenthub stop, xóa dashboard.db-wal và dashboard.db-shm, chép bản backup đè lên dashboard.db (đường dẫn ở trang Hệ thống), rồi agenthub start."
},
"reports": { "reports": {
"title": "Báo cáo định kỳ", "title": "Báo cáo định kỳ",
"description": "Bản tóm tắt những ngày gần nhất — phiên, sự kiện, token, chi phí theo model và theo công cụ AI, công cụ dùng nhiều nhất, ngân sách — ghi ra HTML hoặc JSON theo lịch. Báo cáo và lịch sử chạy được giữ lại khi Xóa dữ liệu.", "description": "Bản tóm tắt những ngày gần nhất — phiên, sự kiện, token, chi phí theo model và theo công cụ AI, công cụ dùng nhiều nhất, ngân sách — ghi ra HTML hoặc JSON theo lịch. Báo cáo và lịch sử chạy được giữ lại khi Xóa dữ liệu.",
@@ -985,5 +1013,12 @@
"title": "Giá model", "title": "Giá model",
"description": "Giá model của mọi nhà cung cấp nằm chung một trang.", "description": "Giá model của mọi nhà cung cấp nằm chung một trang.",
"open": "Mở trang Giá" "open": "Mở trang Giá"
},
"chatUi": {
"title": "Giao diện chat",
"description": "Cách một session Claude hoặc Codex mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"label": "Mở session dạng chat",
"labelDesc": "Hiện hội thoại của mọi session Claude hoặc Codex như Claude Desktop, kèm ô nhập tin, thay cho terminal. Hỏi quyền và menu vẫn cần terminal: tắt mục này để trả lời chúng.",
"saveFailed": "Không lưu được: {{message}}"
} }
} }
+5 -5
View File
@@ -14,12 +14,12 @@
"roles": { "roles": {
"admin": "Admin", "admin": "Admin",
"analyst": "Analyst", "analyst": "Analyst",
"member": "Thành viên" "developer": "Developer"
}, },
"roleHint": { "roleHint": {
"admin": "Toàn quyền, kể cả cấu hình của server và dữ liệu của mọi người", "admin": "Toàn quyền, kể cả cấu hình của server và dữ liệu của mọi người",
"analyst": "Xem được mọi thứ; không có terminal, không thay đổi gì", "analyst": "Xem được dữ liệu của mọi người; chỉ làm việc trong workspace của mình, không đổi cài đặt",
"member": "Chỉ workspace của chính mình" "developer": "Chỉ workspace của chính mình; không đổi cài đặt"
}, },
"add": "Thêm người dùng", "add": "Thêm người dùng",
"username": "Tên đăng nhập", "username": "Tên đăng nhập",
@@ -48,7 +48,7 @@
"password": "Đặt lại mật khẩu cho {{name}}", "password": "Đặt lại mật khẩu cho {{name}}",
"passwordBody": "{{name}} bị đăng xuất khỏi mọi nơi và đăng nhập lại bằng mật khẩu mới.", "passwordBody": "{{name}} bị đăng xuất khỏi mọi nơi và đăng nhập lại bằng mật khẩu mới.",
"workspace": "Giao workspace cho {{name}}", "workspace": "Giao workspace cho {{name}}",
"workspaceBody": "Các phiên và chi phí của workspace chuyển sang {{name}}. Thành viên chỉ thấy workspace của chính mình." "workspaceBody": "Các phiên và chi phí của workspace chuyển sang {{name}}. Developer chỉ thấy workspace của chính mình."
}, },
"done": { "done": {
"created": "Đã thêm {{name}}", "created": "Đã thêm {{name}}",
@@ -68,6 +68,6 @@
}, },
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.", "readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
"auditTitle": "Nhật ký audit", "auditTitle": "Nhật ký audit",
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một thành viên.", "auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
"auditEmpty": "Chưa ghi nhận gì." "auditEmpty": "Chưa ghi nhận gì."
} }
@@ -2,8 +2,6 @@
"title": "Quy trình", "title": "Quy trình",
"subtitle": "Phân tích điều phối Agent", "subtitle": "Phân tích điều phối Agent",
"failedLoad": "Không thể tải dữ liệu quy trình", "failedLoad": "Không thể tải dữ liệu quy trình",
"filteredBy": "Đang lọc theo:",
"clearFilter": "Xóa bộ lọc",
"refreshData": "Làm mới dữ liệu", "refreshData": "Làm mới dữ liệu",
"exportJson": "Xuất JSON", "exportJson": "Xuất JSON",
"allSessions": "Tất cả phiên", "allSessions": "Tất cả phiên",
@@ -238,8 +236,6 @@
"noToolEvents": "Không có sự kiện công cụ.", "noToolEvents": "Không có sự kiện công cụ.",
"noEvents": "Không có sự kiện cho phiên này.", "noEvents": "Không có sự kiện cho phiên này.",
"showingOf": "Đang hiển thị 100 / {{total}} sự kiện", "showingOf": "Đang hiển thị 100 / {{total}} sự kiện",
"sessionInfo": " phiên - hoạt động {{from}}%-{{to}} thời lượng phiên",
"sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)",
"openSession": "Mở phiên", "openSession": "Mở phiên",
"openInWorkspace": "Mở trong workspace" "openInWorkspace": "Mở trong workspace"
}, },
@@ -266,7 +262,6 @@
"subtitle": "Điểm tập trung lỗi theo độ sâu phân cấp Agent", "subtitle": "Điểm tập trung lỗi theo độ sâu phân cấp Agent",
"noErrors": "Không ghi nhận lỗi", "noErrors": "Không ghi nhận lỗi",
"allSuccess": "Tất cả phiên đã hoàn tất thành công", "allSuccess": "Tất cả phiên đã hoàn tất thành công",
"sessionsHadErrors": " phiên gặp lỗi",
"sessionsErrorSummary": "{{errorSessions}} trên {{totalSessions}} phiên gặp lỗi", "sessionsErrorSummary": "{{errorSessions}} trên {{totalSessions}} phiên gặp lỗi",
"agentErrors": " lỗi cấp Agent trong toàn bộ phân cấp", "agentErrors": " lỗi cấp Agent trong toàn bộ phân cấp",
"sessionErrorsOnly": "Chỉ có lỗi ở cấp phiên", "sessionErrorsOnly": "Chỉ có lỗi ở cấp phiên",
@@ -306,7 +301,6 @@
"ofTotal": "trên {{total}}", "ofTotal": "trên {{total}}",
"avgPerSession": "TB / phiên bị ảnh hưởng", "avgPerSession": "TB / phiên bị ảnh hưởng",
"peakSession": "Nhiều nhất trong một phiên", "peakSession": "Nhiều nhất trong một phiên",
"tokensRecovered": "Token thu hồi",
"distribution": "Số phiên theo số lần nén", "distribution": "Số phiên theo số lần nén",
"xAxis": "Số lần nén mỗi phiên", "xAxis": "Số lần nén mỗi phiên",
"yAxis": "Số phiên", "yAxis": "Số phiên",
+43 -6
View File
@@ -59,11 +59,36 @@ describe("openInLayout", () => {
expect(out.focus).toBe(3); expect(out.focus).toBe(3);
}); });
it("splits the focused pane to the right on a split open", () => { it("fills a split open into the 2×2 grid: right, under the right, under the left", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 }; let l: PaneLayout = one;
const out = openInLayout(l, 2, "split"); l = openInLayout(l, 1, "split");
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1))); expect(l.root).toEqual(split("row", pane(0), pane(1)));
expect(out.focus).toBe(2); // The focus is on the left pane: the grid order wins, not the focus.
l = openInLayout({ ...l, focus: 0 }, 2, "split");
expect(l.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
l = openInLayout(l, 3, "split");
expect(l.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(l.focus).toBe(3);
});
it("a split open fills the one gap of a three-pane layout, whatever its shape", () => {
const left: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
expect(openInLayout(left, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
);
// A dragged divider does not move the gap.
const wide: PaneLayout = {
root: split("row", pane(0), split("col", pane(1), pane(2)), 0.3),
focus: 1,
};
expect(openInLayout(wide, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)), 0.3)
);
}); });
it("replaces instead of splitting once MAX_PANES are shown", () => { it("replaces instead of splitting once MAX_PANES are shown", () => {
@@ -226,7 +251,7 @@ describe("pane groups (tabs of up to four panes)", () => {
for (const s of [1, 2, 3]) g = openInGroups(g, s, true); for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES); expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
const out = openInGroups(g, 4, true); const out = openInGroups(g, 4, true);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]); expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 3, 1, 2], [4]]);
expect(out.active).toBe(1); expect(out.active).toBe(1);
}); });
@@ -384,6 +409,18 @@ describe("joinGroup", () => {
expect(g.groups[0]?.focus).toBe(3); expect(g.groups[0]?.focus).toBe(3);
}); });
it("fills the gap under the right pane when the left one is already split", () => {
const combo: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
const g = joinGroup({ groups: [combo, solo(3)], active: 1 }, [3], 0);
expect(g.groups).toHaveLength(1);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
);
});
it("activates the target tab and takes a pane out of a combo", () => { 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 combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1); const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
+61 -2
View File
@@ -553,7 +553,7 @@ export type AuditEntry = {
}; };
/** Who is using the dashboard: local mode has no login at all. */ /** 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 AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null }; export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GETs in flight, by path: concurrent callers of the same read (several /** GETs in flight, by path: concurrent callers of the same read (several
@@ -596,7 +596,12 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
if (res.status === 401 && body?.error?.code === "EAUTH") { if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT)); window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
} }
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), { // Server mode: a role refusal reads the same in every toast, translated.
const message =
body?.error?.code === "EADMIN"
? i18n.t("errors:adminOnly")
: body?.error?.message || `HTTP ${res.status}`;
throw Object.assign(new Error(message), {
code: body?.error?.code, code: body?.error?.code,
status: res.status, status: res.status,
// EGITAUTH from a lane git action: which host wants a login. // EGITAUTH from a lane git action: which host wants a login.
@@ -670,6 +675,14 @@ export const api = {
/** POST /api/auth/logout — ends this login. */ /** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }), logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
}, },
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
me: {
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
prefs: () => request<MePrefs>("/me/prefs"),
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
savePrefs: (prefs: Record<string, string | null>) =>
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ───────────────────────── // ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: { users: {
/** GET /api/users — every account with its recent usage (admin/analyst). */ /** GET /api/users — every account with its recent usage (admin/analyst). */
@@ -1253,6 +1266,13 @@ export const api = {
/** POST /api/settings/quit — close every workspace terminal and stop the /** POST /api/settings/quit — close every workspace terminal and stop the
* server until `agenthub start` (or a boot/login with autostart on). */ * server until `agenthub start` (or a boot/login with autostart on). */
quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }), quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }),
/** POST /api/settings/uninstall — remove AgentHub from this machine, its
* database included, in a detached worker (scripts/uninstall.js). */
uninstall: () =>
request<{ ok: true; log: string }>("/settings/uninstall", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
/** Opt-in secret redaction of stored payloads and the folders whose hooks /** Opt-in secret redaction of stored payloads and the folders whose hooks
* are dropped (`server/lib/privacy.js`). */ * are dropped (`server/lib/privacy.js`). */
privacy: { privacy: {
@@ -1265,6 +1285,19 @@ export const api = {
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
}, },
/** The daily database backup (`server/lib/db-backup.js`). */
backup: {
/** GET /api/settings/backup */
get: () => request<BackupState>("/settings/backup"),
/** PUT /api/settings/backup — any subset; an empty `dir` turns it off */
save: (data: { dir?: string; hour?: number; keep?: number }) =>
request<BackupState & { ok: boolean }>("/settings/backup", {
method: "PUT",
body: JSON.stringify(data),
}),
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is /** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */ * stored: `rotate` returns the new token this once, `get` never does. */
pushToken: { pushToken: {
@@ -4279,6 +4312,12 @@ export interface RunStartArgs {
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session /** A lane's tmux-backed terminal run — one per lane, id is the tmux session
* name (`agenthub-lane-<laneId>`). */ * name (`agenthub-lane-<laneId>`). */
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
export interface MePrefs {
prefs: Record<string, string>;
onboardedAt: string | null;
}
export interface RunHandle { export interface RunHandle {
id: string; id: string;
laneId: number | null; laneId: number | null;
@@ -4510,6 +4549,26 @@ export interface PrivacySettings {
env_owned: { redact_secrets: boolean; ignore_cwd: boolean }; env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
} }
/** GET /api/settings/backup. `files` are AgentHub's own backups in `dir`,
* newest first; `last` is this server process's last run. */
export interface BackupState {
dir: string;
hour: number;
keep: number;
running: boolean;
last: {
at: string;
ok: boolean;
file?: string;
size?: number;
ms?: number;
error?: string;
} | null;
files: { name: string; size: number; mtime: string }[];
dir_error: string | null;
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */ /** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus { export interface PushTokenStatus {
enabled: boolean; enabled: boolean;
+65
View File
@@ -0,0 +1,65 @@
/**
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
* any browser and differs between users of one dashboard. Read once per page
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
* once and puts the old value back if the save fails.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
let chatUi = false;
let loading: Promise<void> | null = null;
const listeners = new Set<() => void>();
const emit = (next: boolean) => {
chatUi = next;
listeners.forEach((fn) => fn());
};
function load() {
// A failed read (logged out, offline) must never take a page down with it.
loading ??= Promise.resolve()
.then(() => api.me.prefs())
.then(
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
() => {
loading = null; // the next reader asks again
}
);
}
export function useChatUi(): boolean {
useEffect(load, []);
return useSyncExternalStore(
(fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
() => chatUi
);
}
export async function saveChatUi(on: boolean): Promise<void> {
const before = chatUi;
emit(on);
try {
const r = await api.me.savePrefs({ [CHAT_UI_KEY]: on ? "1" : null });
emit(r.prefs[CHAT_UI_KEY] === "1");
} catch (err) {
emit(before);
throw err;
}
}
/** Tests: forget what was read. */
export function __resetChatUi() {
chatUi = false;
loading = null;
}
+8
View File
@@ -552,3 +552,11 @@ export function pathBasename(p: string | null | undefined): string | null {
const idx = trimmed.lastIndexOf("/"); const idx = trimmed.lastIndexOf("/");
return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed; return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed;
} }
/** 1536 → "1.5 KB"; binary units, one decimal from KB up. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
+24 -20
View File
@@ -101,9 +101,28 @@ function focusOf(layout: PaneLayout): number | null {
return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null); return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null);
} }
/** The pane a new one splits, and on which side, filling a 2×2 grid in
* order: right of a lone pane, under the right one, under the left one. The
* biggest pane by the tree's SHAPE (dragged ratios ignored; a tie goes to the
* later pane) is the gap — so a three-pane tab of any shape fills its one
* missing quarter. A tall pane splits down, any other to the right. */
function nextSpot(root: PaneNode): [number, Edge] {
const even = (n: PaneNode): PaneNode =>
n.type === "pane" ? n : { ...n, ratio: 0.5, a: even(n.a), b: even(n.b) };
const best = layoutRects(even(root)).panes.reduce((m, r) => (r.w * r.h >= m.w * m.h ? r : m));
return [best.slot, best.h > best.w ? "bottom" : "right"];
}
/** Adds `slot` to the layout at the grid's next place (`nextSpot`), focused. */
function placeInLayout(layout: PaneLayout, slot: number): PaneLayout {
if (!layout.root) return { root: pane(slot), focus: slot };
const [target, edge] = nextSpot(layout.root);
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
}
/** /**
* Shows `slot`: focuses it when it is already on screen; otherwise `split` * Shows `slot`: focuses it when it is already on screen; otherwise `split`
* opens it to the right of the focused pane (while there is room) and * adds it at the grid's next place (`nextSpot`, while there is room) and
* `replace` puts it in the focused pane. * `replace` puts it in the focused pane.
*/ */
export function openInLayout( export function openInLayout(
@@ -114,12 +133,9 @@ export function openInLayout(
if (!layout.root) return { root: pane(slot), focus: slot }; if (!layout.root) return { root: pane(slot), focus: slot };
const shown = leaves(layout.root); const shown = leaves(layout.root);
if (shown.includes(slot)) return { ...layout, focus: slot }; if (shown.includes(slot)) return { ...layout, focus: slot };
if (mode === "split" && shown.length < MAX_PANES) return placeInLayout(layout, slot);
const target = focusOf(layout) as number; const target = focusOf(layout) as number;
const root = return { root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s)), focus: slot };
mode === "split" && shown.length < MAX_PANES
? splitLeaf(layout.root, target, slot, "right")
: mapLeaves(layout.root, (s) => pane(s === target ? slot : s));
return { root, focus: slot };
} }
/** Closes `slot`'s pane (its session keeps running as a tab). */ /** Closes `slot`'s pane (its session keeps running as a tab). */
@@ -289,7 +305,7 @@ export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout):
/** /**
* Shows `slot`. Its own tab is activated (and the slot focused there); a * Shows `slot`. Its own tab is activated (and the slot focused there); a
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+ * session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
* click) brings it into the active tab beside the focused pane instead — * click) brings it into the active tab at the grid's next place instead —
* unless that tab is already full, when it falls back to the plain open. * unless that tab is already full, when it falls back to the plain open.
*/ */
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups { export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
@@ -327,18 +343,6 @@ export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups
return compact(groups, at); 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 /** 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 * into the tab at `to`, which becomes active. Same object back when nothing
* would move or the tab would pass `MAX_PANES`. */ * would move or the tab would pass `MAX_PANES`. */
@@ -351,7 +355,7 @@ export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroup
}); });
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g; if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
const groups = g.groups.map((l, i) => const groups = g.groups.map((l, i) =>
i === to ? moving.reduce(joinLayout, l) : moving.reduce(closeInLayout, l) i === to ? moving.reduce(placeInLayout, l) : moving.reduce(closeInLayout, l)
); );
return compact(groups, to); return compact(groups, to);
} }
+14 -3
View File
@@ -7,12 +7,14 @@
* bracketed, so `claude` takes it as one block in its composer — and never an * bracketed, so `claude` takes it as one block in its composer — and never an
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one * Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
* hidden behind a full-width Changes panel or a replay would swallow it * hidden behind a full-width Changes panel or a replay would swallow it
* unseen); the caller says where it went, or that it went nowhere. * unseen); the caller says where it went, or that it went nowhere. The one
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
* what the user typed there, so it pastes AND presses Enter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
interface Pane { interface Pane {
paste: (text: string) => void; paste: (text: string, submit?: boolean) => void;
visible: () => boolean; visible: () => boolean;
} }
const panes = new Map<string, Pane>(); const panes = new Map<string, Pane>();
@@ -22,7 +24,7 @@ let lastFocused: string | null = null;
* unregister. */ * unregister. */
export function registerPane( export function registerPane(
runId: string, runId: string,
paste: (text: string) => void, paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true visible: () => boolean = () => true
): () => void { ): () => void {
const entry = { paste, visible }; const entry = { paste, visible };
@@ -52,6 +54,15 @@ export function pasteToRun(runId: string, text: string): boolean {
return true; return true;
} }
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
* False when the pane is not mounted or not attached. */
export function submitToRun(runId: string, text: string): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text, true);
return true;
}
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused /** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
* last when it is — and returns its run id; null when none is on screen. */ * last when it is — and returns its run id; null when none is on screen. */
export function pasteToLane(laneId: number, text: string): string | null { export function pasteToLane(laneId: number, text: string): string | null {
+8 -1
View File
@@ -32,6 +32,8 @@ export const SETTINGS_INDEX: {
descKey?: string; descKey?: string;
keywords?: string[]; keywords?: string[];
to?: string; to?: string;
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
adminOnly?: boolean;
}[] = [ }[] = [
{ {
section: "appearance", section: "appearance",
@@ -114,18 +116,21 @@ export const SETTINGS_INDEX: {
{ {
section: "system", section: "system",
titleKey: "autostart.label", titleKey: "autostart.label",
adminOnly: true,
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"], keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
to: "#autostart", to: "#autostart",
}, },
{ {
section: "system", section: "system",
titleKey: "quit.label", titleKey: "quit.label",
adminOnly: true,
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"], keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
to: "#autostart", to: "#autostart",
}, },
{ {
section: "system", section: "system",
titleKey: "tray.label", titleKey: "tray.label",
adminOnly: true,
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"], keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
to: "#autostart", to: "#autostart",
}, },
@@ -154,6 +159,7 @@ export const SETTINGS_INDEX: {
{ {
section: "reports", section: "reports",
titleKey: "reports.title", titleKey: "reports.title",
adminOnly: true,
descKey: "reports.description", descKey: "reports.description",
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"], keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
}, },
@@ -189,6 +195,7 @@ export const SETTINGS_INDEX: {
{ {
section: "notifications", section: "notifications",
titleKey: "webhooks.title", titleKey: "webhooks.title",
adminOnly: true,
keywords: ["slack", "discord", "telegram", "channels"], keywords: ["slack", "discord", "telegram", "channels"],
to: "#alerts", to: "#alerts",
}, },
@@ -279,7 +286,7 @@ export const SETTINGS_INDEX: {
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] }, { section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] }, { section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] }, { section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git", titleKey: "gitCreds.title", keywords: ["login", "token"] }, { section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
{ {
section: "git", section: "git",
titleKey: "worktrees.showUntracked", titleKey: "worktrees.showUntracked",
+3
View File
@@ -1683,6 +1683,9 @@ export interface AlertEvent {
/** Agent the alert pertains to; null when not agent-specific. FK into /** Agent the alert pertains to; null when not agent-specific. FK into
* `Agent.id` when set. */ * `Agent.id` when set. */
agent_id: string | null; agent_id: string | null;
/** Server mode, an analyst's feed only: whether this alert is one of their
* own sessions' and so theirs to acknowledge. Absent = acknowledgeable. */
can_ack?: boolean;
/** Human-readable description of what triggered the alert. Example: /** Human-readable description of what triggered the alert. Example:
* `"Session idle for 32 minutes"`. */ * `"Session idle for 32 minutes"`. */
message: string; message: string;
+27 -11
View File
@@ -94,8 +94,10 @@ import { SessionOverview } from "../components/SessionOverview";
import { ConversationView } from "../components/conversation/ConversationView"; import { ConversationView } from "../components/conversation/ConversationView";
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge"; import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge"; import { SessionToolBadge } from "../components/SessionToolBadge";
import { SessionResumeButton } from "../components/lanes/SessionResumeButton"; import { SessionResumeButton, owningLane } from "../components/lanes/SessionResumeButton";
import { SnapshotButton } from "../components/SnapshotButton"; import { SnapshotButton } from "../components/SnapshotButton";
import { usePermissions } from "../components/AuthGate";
import { useLanes } from "../hooks/useLanes";
import { EventTimeline } from "../components/EventTimeline"; import { EventTimeline } from "../components/EventTimeline";
import { EventReplay, loadReplayEvents } from "../components/EventReplay"; import { EventReplay, loadReplayEvents } from "../components/EventReplay";
import { CopyButton } from "../components/event-views/primitives"; import { CopyButton } from "../components/event-views/primitives";
@@ -151,6 +153,9 @@ export function SessionDetail() {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const { t: wfT } = useTranslation("workflows"); const { t: wfT } = useTranslation("workflows");
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
// Server mode: an analyst reads every session but acts only on their own.
const { canEditLane } = usePermissions();
const { lanes } = useLanes();
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
const [workflows, setWorkflows] = useState<WorkflowRun[]>([]); const [workflows, setWorkflows] = useState<WorkflowRun[]>([]);
const [events, setEvents] = useState<DashboardEvent[]>([]); const [events, setEvents] = useState<DashboardEvent[]>([]);
@@ -666,16 +671,27 @@ export function SessionDetail() {
</div> </div>
)} )}
</div> </div>
{(() => { {/* The owning workspace: its stamp, else the one whose folder holds it
const href = workspaceHref(session, runs); (a session a pane holds but no hook stamped yet). */}
return href ? ( {canEditLane(
<Link to={href} className="btn-ghost"> lanes.find((l) => l.id === session.lane_id) ??
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")} (session.lane_id == null && session.cwd
</Link> ? owningLane(session.cwd, session.tool || "claude", lanes)
) : null; : null)
})()} ) && (
<SessionResumeButton session={session} /> <>
<SnapshotButton sessionId={session.id} /> {(() => {
const href = workspaceHref(session, runs);
return href ? (
<Link to={href} className="btn-ghost">
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
</Link>
) : null;
})()}
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
</>
)}
<button <button
onClick={refresh} onClick={refresh}
disabled={spinning} disabled={spinning}
+8 -10
View File
@@ -1,6 +1,6 @@
/** /**
* @file Settings.tsx * @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`. * @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -85,7 +85,7 @@ import {
import { api } from "../lib/api"; import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { fmt, fmtCost } from "../lib/format"; import { fmt, fmtCost, formatBytes } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push"; import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import { import {
defaultNotifPrefs, defaultNotifPrefs,
@@ -102,7 +102,9 @@ import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources"; import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings"; import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings"; import { PrivacySettings } from "../components/settings/PrivacySettings";
import { BackupSettings } from "../components/settings/BackupSettings";
import { AutostartSettings } from "../components/settings/AutostartSettings"; import { AutostartSettings } from "../components/settings/AutostartSettings";
import { usePermissions } from "../components/AuthGate";
import { Skeleton } from "../components/Skeleton"; import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications"; import { AlertsNotifications } from "../components/AlertsNotifications";
import { import {
@@ -142,13 +144,6 @@ const NOTIF_TYPES: [NotifType, string][] = [
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>; type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400); const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600); const h = Math.floor((seconds % 86400) / 3600);
@@ -201,6 +196,7 @@ export function Settings({
section?: "data" | "about" | "notifications"; section?: "data" | "about" | "notifications";
} = {}) { } = {}) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { isAdmin } = usePermissions();
// Only Data and About show server info; General and Notifications never read it. // Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about"; const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo); const [loading, setLoading] = useState(needsInfo);
@@ -966,6 +962,7 @@ export function Settings({
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */} {/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings /> <PushIngestSettings />
<PrivacySettings /> <PrivacySettings />
<BackupSettings />
{/* ─── SESSION CLEANUP ─── */} {/* ─── SESSION CLEANUP ─── */}
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24"> <section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
@@ -1120,7 +1117,8 @@ export function Settings({
{section === "about" && ( {section === "about" && (
<div id="about" className="space-y-6 scroll-mt-24"> <div id="about" className="space-y-6 scroll-mt-24">
<AutostartSettings /> {/* Start at login, the icon, Quit and Uninstall act on the host: an admin's. */}
{isAdmin && <AutostartSettings />}
{sysInfo ? ( {sysInfo ? (
<SystemDetails info={sysInfo} /> <SystemDetails info={sysInfo} />
) : ( ) : (
+62 -26
View File
@@ -16,7 +16,9 @@
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and * (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the * Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is * generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here. * disabled. Those pages are not in the nav: they are tabs on AI tools (the nav
* marks AI tools current, the search shows their matches under it), and a
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form. * The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -65,7 +67,8 @@ import {
import { Skeleton } from "../components/Skeleton"; import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon"; import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate"; import { usePermissions } from "../components/AuthGate";
import { PageTabs } from "../components/PageTabs";
const GROUPS = ["general", "agents", "data"] as const; const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number]; type Group = (typeof GROUPS)[number];
@@ -77,8 +80,13 @@ interface Section {
description: string; description: string;
Icon: ComponentType<{ className?: string }>; Icon: ComponentType<{ className?: string }>;
render: () => ReactNode; render: () => ReactNode;
/** A tool's own page: a tab on AI tools, not a nav item. */
tool?: string;
} }
/** The nav item a section sits under: a tool's page is a tab on AI tools. */
const navId = (id: string) => (id.startsWith("agents/") ? "agents" : id);
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */ /** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = { const LEGACY_TOOL: Record<string, string> = {
dashboard: "appearance", dashboard: "appearance",
@@ -131,8 +139,8 @@ const MOVED: Record<string, string> = {
worktrees: "git#worktrees", worktrees: "git#worktrees",
}; };
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */ /** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]); const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
/** An unknown section: say so, then show Appearance. */ /** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) { function UnknownSection({ name }: { name: string }) {
@@ -215,8 +223,7 @@ export function SettingsHub() {
}; };
}, [location.key, location.hash]); }, [location.key, location.hash]);
const auth = useAuth(); const { isAdmin } = usePermissions();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false; const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({ const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`), title: t(`settingsNav.section.${key}.title`),
@@ -249,13 +256,14 @@ export function SettingsHub() {
group: "agents", group: "agents",
Icon: Bot, Icon: Bot,
...s("agents"), ...s("agents"),
render: () => <AiToolsSettings />, render: () => <AiToolsSettings settingsHref={toolHref} />,
}, },
...(enabled("claude") ...(enabled("claude")
? [ ? [
{ {
id: "agents/claude-code", id: "agents/claude-code",
group: "agents" as const, group: "agents" as const,
tool: "claude",
Icon: toolNavIcon("claude", "Claude Code"), Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"), ...s("claudeCode"),
render: () => <CcConfig />, render: () => <CcConfig />,
@@ -267,6 +275,7 @@ export function SettingsHub() {
{ {
id: "agents/codex", id: "agents/codex",
group: "agents" as const, group: "agents" as const,
tool: "codex",
Icon: toolNavIcon("codex", "Codex"), Icon: toolNavIcon("codex", "Codex"),
...s("codex"), ...s("codex"),
render: () => <CodexConfig />, render: () => <CodexConfig />,
@@ -280,6 +289,7 @@ export function SettingsHub() {
.map((x) => ({ .map((x) => ({
id: `agents/${x.id}`, id: `agents/${x.id}`,
group: "agents" as const, group: "agents" as const,
tool: x.id,
Icon: toolNavIcon(x.id, x.label), Icon: toolNavIcon(x.id, x.label),
title: x.label, title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }), description: t("settingsNav.section.tool.description", { tool: x.label }),
@@ -335,8 +345,8 @@ export function SettingsHub() {
<div id="snapshots" className="scroll-mt-24"> <div id="snapshots" className="scroll-mt-24">
<SnapshotSettings /> <SnapshotSettings />
</div> </div>
{/* Scheduled reports read every user's sessions: staff only. */} {/* Scheduled reports read every user's sessions: an admin's. */}
{!isMember && <ReportSettings />} {isAdmin && <ReportSettings />}
</div> </div>
), ),
}, },
@@ -347,8 +357,8 @@ export function SettingsHub() {
...s("git"), ...s("git"),
render: () => ( render: () => (
<div className="space-y-6"> <div className="space-y-6">
{/* Git logins are the host's own: staff only. */} {/* Git logins are the host's own: an admin's. */}
{!isMember && ( {isAdmin && (
<section id="git-credentials" className="scroll-mt-24"> <section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2> <h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig /> <GitCredentialsConfig />
@@ -361,18 +371,27 @@ export function SettingsHub() {
), ),
}, },
]; ];
// Server mode: a member cannot open the host's own configuration or the // Server mode: only an admin changes the host's own configuration (the
// all-users views (the server answers 403 EADMIN), so those pages are hidden. // server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections; const sections = isAdmin
? allSections
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
const page = toolTabs.find((x) => x.tool === id);
return page ? `/settings/${page.id}` : undefined;
}
const entries: SettingsEntry[] = sections.flatMap((sec) => [ const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true }, { section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
...SETTINGS_INDEX.filter( ...SETTINGS_INDEX.filter(
(e) => (e) =>
e.section === sec.id || (isAdmin || !e.adminOnly) &&
(e.section === "agents/*" && (e.section === sec.id ||
sec.id.startsWith("agents/") && (e.section === "agents/*" &&
!SETTINGS_INDEX.some((x) => x.section === sec.id)) sec.id.startsWith("agents/") &&
!SETTINGS_INDEX.some((x) => x.section === sec.id)))
).map((e) => ({ ).map((e) => ({
section: sec.id, section: sec.id,
title: t(e.titleKey), title: t(e.titleKey),
@@ -410,8 +429,11 @@ export function SettingsHub() {
} }
const ranked = query.trim() ? rankSettings(query, entries) : null; const ranked = query.trim() ? rankSettings(query, entries) : null;
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections; const shown = ranked
const first = ranked?.[0]?.section ?? null; ? navSections.filter((x) => ranked.some((r) => navId(r.section) === x.id))
: navSections;
const first = ranked?.[0] ? navId(ranked[0].section) : null;
const navActive = navId(active.id);
const results = ranked ? rankSettingsEntries(query, entries) : []; const results = ranked ? rankSettingsEntries(query, entries) : [];
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id; const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`; const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
@@ -494,10 +516,10 @@ export function SettingsHub() {
<Link <Link
key={id} key={id}
to={`/settings/${id}`} to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined} aria-current={id === navActive ? "page" : undefined}
data-first-match={id === first ? "true" : undefined} data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${ className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
id === active.id id === navActive
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent" ? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
: id === first : id === first
? "bg-surface-3 text-fg-primary" ? "bg-surface-3 text-fg-primary"
@@ -505,7 +527,7 @@ export function SettingsHub() {
}`} }`}
> >
<Icon <Icon
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`} className={`h-4 w-4 shrink-0 ${id === navActive ? "text-accent" : "text-fg-muted"}`}
/> />
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
</Link> </Link>
@@ -521,12 +543,12 @@ export function SettingsHub() {
<div className="mb-4 md:hidden"> <div className="mb-4 md:hidden">
<select <select
aria-label={t("settingsNav.label")} aria-label={t("settingsNav.label")}
value={active.id} value={navActive}
onChange={(e) => go(e.target.value)} onChange={(e) => go(e.target.value)}
className="select w-full py-1.5" className="select w-full py-1.5"
> >
{GROUPS.map((g) => { {GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g); const own = navSections.filter((x) => x.group === g);
return own.length === 0 ? null : ( return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}> <optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => ( {own.map((x) => (
@@ -548,7 +570,21 @@ export function SettingsHub() {
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p> <p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
</div> </div>
</header> </header>
{active.render()} {navActive === "agents" && toolTabs.length > 0 ? (
<PageTabs
ariaLabel={t("settingsNav.toolTabs")}
activeId={active.id}
onSelect={go}
tabs={[
{ id: "agents", label: t("settingsNav.toolsTab"), Icon: Bot },
...toolTabs.map(({ id, title, Icon }) => ({ id, label: title, Icon })),
]}
>
<div className="pt-4">{active.render()}</div>
</PageTabs>
) : (
active.render()
)}
</div> </div>
</div> </div>
</div> </div>
+5 -5
View File
@@ -25,7 +25,7 @@ import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow"; import { SettingsSection } from "../components/settings/SettingsRow";
type Role = AuthUser["role"]; type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "member"]; const ROLES: Role[] = ["admin", "analyst", "developer"];
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace"; type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
type Dialog = { kind: DialogKind; user?: ServerUserUsage }; type Dialog = { kind: DialogKind; user?: ServerUserUsage };
@@ -61,11 +61,11 @@ export function Users() {
const [spinning, refresh] = useSpin(load); const [spinning, refresh] = useSpin(load);
useEffect(() => { useEffect(() => {
if (me && me.role !== "member") void load(); if (me && me.role !== "developer") void load();
}, [me, load]); }, [me, load]);
// Local mode, or a member: there is no Users page for them. // Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "member") return <Navigate to="/" replace />; if (!me || me.role === "developer") return <Navigate to="/" replace />;
return ( return (
<div className="animate-fade-in space-y-6"> <div className="animate-fade-in space-y-6">
@@ -265,7 +265,7 @@ function AccountDialog({
const name = user?.username ?? ""; const name = user?.username ?? "";
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>(user?.role ?? "member"); const [role, setRole] = useState<Role>(user?.role ?? "developer");
const [laneId, setLaneId] = useState<number | null>(null); const [laneId, setLaneId] = useState<number | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null); const [lanes, setLanes] = useState<Lane[] | null>(null);
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
+5 -5
View File
@@ -69,7 +69,7 @@ import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import { confirmDialog, confirmDiscard } from "../lib/confirm"; import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame"; import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes"; import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { import {
ConsoleTabs, ConsoleTabs,
@@ -168,7 +168,7 @@ function slotOf(runId: string | null | undefined): number {
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain * lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
* open (a tab, a sidebar row) shows the tab holding that run — a session in * open (a tab, a sidebar row) shows the tab holding that run — a session in
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it * no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
* into the tab on screen, to the right of the focused pane. Every live run * into the tab on screen, at the 2×2 grid's next place. Every live run
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither * stays MOUNTED, hidden with CSS while its tab is not shown, so neither
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose * switching tabs nor reshaping the panes ever drops a terminal. A pane whose
* run ends closes; the last tab gone leaves for the home. */ * run ends closes; the last tab gone leaves for the home. */
@@ -189,7 +189,7 @@ function ConsoleArea({
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
onHasActiveRunChange: (val: boolean) => void; onHasActiveRunChange: (val: boolean) => void;
/** The opened run (`?run=`): its pane is the focused one. `split` opens it /** The opened run (`?run=`): its pane is the focused one. `split` opens it
* beside the focused pane instead of in it; `focusOnly` (the URL following a * in a split (the grid's next place) instead; `focusOnly` (the URL following a
* focus change made here) never opens anything. */ * focus change made here) never opens anything. */
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null; attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
/** Back to the workspace home, where a new session is started. */ /** Back to the workspace home, where a new session is started. */
@@ -519,7 +519,7 @@ export function Workspace() {
// Lane state: the list is the shared store the sidebar reads too; the // Lane state: the list is the shared store the sidebar reads too; the
// selection is the URL's `?lane=` (the sidebar sets it, so does a // selection is the URL's `?lane=` (the sidebar sets it, so does a
// notification click), falling back to the last one shown, then the first. // notification click), falling back to the last one shown, then the first.
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes(); const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useWorkLanes();
const laneParam = searchParams.get("lane"); const laneParam = searchParams.get("lane");
// `?run=` — a live session picked in the sidebar; the location key makes a // `?run=` — a live session picked in the sidebar; the location key makes a
// second pick of the same session a new request. // second pick of the same session a new request.
@@ -529,7 +529,7 @@ export function Workspace() {
// conversation, with a prompt box whose message resumes it (SessionReplay). // conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session"); const sessionParam = runParam ? null : searchParams.get("session");
const navigate = useNavigate(); const navigate = useNavigate();
// A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane; // A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
// it rides on the navigation's state, never in the URL. // it rides on the navigation's state, never in the URL.
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null; const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
const splitOpen = navState?.split === true; const splitOpen = navState?.split === true;
@@ -51,10 +51,12 @@ let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, br
// The header's Resume reads lanes and runs; this file is about the agent tree. // The header's Resume reads lanes and runs; this file is about the agent tree.
vi.mock("../../components/lanes/SessionResumeButton", () => ({ vi.mock("../../components/lanes/SessionResumeButton", () => ({
SessionResumeButton: () => null, SessionResumeButton: () => null,
owningLane: () => null,
})); }));
vi.mock("../../lib/api", () => ({ vi.mock("../../lib/api", () => ({
api: { api: {
lanes: { list: vi.fn(() => Promise.resolve({ lanes: [] })) },
sessions: { sessions: {
get: vi.fn(() => get: vi.fn(() =>
Promise.resolve({ Promise.resolve({
@@ -104,6 +106,7 @@ vi.mock("../../lib/api", () => ({
vi.mock("../../lib/eventBus", () => ({ vi.mock("../../lib/eventBus", () => ({
eventBus: { eventBus: {
subscribe: vi.fn(() => () => {}), subscribe: vi.fn(() => () => {}),
onConnection: vi.fn(() => () => {}),
}, },
})); }));
@@ -65,6 +65,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { redact_secrets: false, ignore_cwd: false }, env_owned: { redact_secrets: false, ignore_cwd: false },
})), })),
}, },
backup: {
get: vi.fn(async () => ({
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
})),
},
}, },
}, },
}; };
@@ -3,7 +3,8 @@
* @description The Settings page shell: a path picks its section, `/settings` * @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an * and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to Appearance, a disabled tool has no page while an * unknown section falls back to Appearance, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile * installed, enabled catalog tool gets one — as a tab on AI tools, not a nav
* item —, the mobile
* select navigates, and the search filters the nav, opens the first match on * select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its * Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is * text), its result list deep-links a row's sub-tab, an anchor in the URL is
@@ -159,13 +160,27 @@ describe("SettingsHub", () => {
view.tools[1] = tool("codex", { launcher: "codex", enabled: false }); view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
renderAt("/settings/agents/gemini"); renderAt("/settings/agents/gemini");
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument(); expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
.getAllByRole("tab")
.map((a) => a.textContent);
expect(tabs).toEqual(["All tools", "Claude Code", "Gemini"]);
});
it("shows tool pages as tabs on AI tools, not as nav items", async () => {
renderAt("/settings/agents/codex");
expect(await screen.findByTestId("codex-config")).toBeInTheDocument();
const links = within(nav()) const links = within(nav())
.getAllByRole("link") .getAllByRole("link")
.map((a) => a.textContent); .map((a) => a.textContent);
expect(links).toContain("Claude Code");
expect(links).not.toContain("Codex"); expect(links).not.toContain("Codex");
expect(links).not.toContain("Qwen"); expect(within(nav()).getByRole("link", { name: "AI tools" })).toHaveAttribute(
expect(links).not.toContain("Aider"); "aria-current",
"page"
);
expect(screen.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("tab", { name: "All tools" }));
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
}); });
it("navigates from the mobile select", async () => { it("navigates from the mobile select", async () => {
+4 -4
View File
@@ -66,7 +66,7 @@ function renderPage() {
beforeEach(() => { beforeEach(() => {
for (const fn of Object.values(users)) fn.mockReset(); for (const fn of Object.values(users)) fn.mockReset();
users.list.mockResolvedValue({ users.list.mockResolvedValue({
users: [row(1, "boss", "admin"), row(2, "mia", "member")], users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
}); });
users.audit.mockResolvedValue({ entries: [] }); users.audit.mockResolvedValue({ entries: [] });
}); });
@@ -76,7 +76,7 @@ describe("Users page", () => {
authState = { mode: "local" }; authState = { mode: "local" };
renderPage(); renderPage();
expect(await screen.findByTestId("home")).toBeTruthy(); expect(await screen.findByTestId("home")).toBeTruthy();
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } }; authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
renderPage(); renderPage();
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0); expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
expect(users.list).not.toHaveBeenCalled(); expect(users.list).not.toHaveBeenCalled();
@@ -94,7 +94,7 @@ describe("Users page", () => {
it("adds a user only with the admin's own password", async () => { it("adds a user only with the admin's own password", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } }; authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.create.mockResolvedValue({ users.create.mockResolvedValue({
user: { id: 9, username: "neo", role: "member", disabled: false }, user: { id: 9, username: "neo", role: "developer", disabled: false },
}); });
const ue = userEvent.setup(); const ue = userEvent.setup();
renderPage(); renderPage();
@@ -111,7 +111,7 @@ describe("Users page", () => {
await ue.click(confirm); await ue.click(confirm);
await waitFor(() => await waitFor(() =>
expect(users.create).toHaveBeenCalledWith( expect(users.create).toHaveBeenCalledWith(
{ username: "neo", password: "neo-pass-123", role: "member" }, { username: "neo", password: "neo-pass-123", role: "developer" },
"boss-pass" "boss-pass"
) )
); );
@@ -732,7 +732,7 @@ describe("split panes", () => {
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]); expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
await ctrlClick(screen.getByTestId("console-tab-4")); await ctrlClick(screen.getByTestId("console-tab-4"));
expect(shownPanes()).toEqual(["pane-4"]); expect(shownPanes()).toEqual(["pane-4"]);
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument(); expect(screen.getByTestId("console-tab-0-3-1-2")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true"); expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
}); });
@@ -7189,6 +7189,35 @@ exports[`screen snapshots > Settings — about 1`] = `
</button> </button>
</div> </div>
</div> </div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Uninstall AgentHub
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
</p>
</div>
<div
class="shrink-0"
>
<button
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
data-testid="uninstall-agenthub"
type="button"
>
Uninstall
</button>
</div>
</div>
</div> </div>
</section> </section>
<section <section
@@ -7220,7 +7249,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.5.12 (rebuild client to match) UI build v2.5.13 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
@@ -9066,6 +9095,205 @@ exit 0
</div> </div>
</section> </section>
</div> </div>
<section
class="card p-5 scroll-mt-24"
id="backup"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
<span
class="flex items-center gap-2"
>
<svg
class="lucide lucide-database-backup w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<ellipse
cx="12"
cy="5"
rx="9"
ry="3"
/>
<path
d="M3 12a9 3 0 0 0 5 2.69"
/>
<path
d="M21 9.3V5"
/>
<path
d="M3 5v14a9 3 0 0 0 6.47 2.88"
/>
<path
d="M12 12v4h4"
/>
<path
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
/>
</svg>
Daily backup
</span>
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.
</p>
<div
class="mt-2"
>
<div
class="py-3 space-y-3"
>
<label
class="block text-xs text-fg-muted"
>
Backup folder
<input
class="input mt-1 w-full font-mono text-xs"
placeholder="/mnt/hdd/agenthub-backups"
value=""
/>
</label>
<p
class="text-xs text-fg-muted"
>
An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.
</p>
<div
class="flex flex-wrap items-end gap-3"
>
<div
class="w-28"
>
<div
class="relative"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Time (server)"
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="Time (server)"
type="button"
>
<span
class="flex min-w-0 items-center gap-2"
>
<span
class="truncate"
>
02:00
</span>
</span>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<label
class="text-xs text-fg-muted"
>
Backups kept
<input
class="input mt-1 block w-24 text-sm"
max="365"
min="1"
step="1"
type="number"
value="7"
/>
</label>
</div>
<div
class="flex flex-wrap items-center gap-2"
>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
Save
</button>
<button
class="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
<svg
class="lucide lucide-database-backup w-4 h-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<ellipse
cx="12"
cy="5"
rx="9"
ry="3"
/>
<path
d="M3 12a9 3 0 0 0 5 2.69"
/>
<path
d="M21 9.3V5"
/>
<path
d="M3 5v14a9 3 0 0 0 6.47 2.88"
/>
<path
d="M12 12v4h4"
/>
<path
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
/>
</svg>
Back up now
</button>
</div>
<p
class="text-xs text-fg-secondary"
>
Backups are off.
</p>
<p
class="text-xs text-fg-muted"
>
To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start.
</p>
</div>
</div>
</section>
<section <section
class="card p-5 space-y-4 scroll-mt-24" class="card p-5 space-y-4 scroll-mt-24"
id="cleanup" id="cleanup"
@@ -350,6 +350,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
}), }),
save: r({ ok: true }), save: r({ ok: true }),
}, },
backup: {
get: r({
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
}),
},
exportData: () => "/api/settings/export", exportData: () => "/api/settings/export",
cleanup: r({ cleanup: r({
ok: true, ok: true,
+26 -2
View File
@@ -84,6 +84,8 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `GET /api/auth/me` | `{mode: "local"}`, or `{mode: "server", user: {id, username, role} \| null}` | | `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/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
| `GET /api/me/prefs` | The caller's OWN UI preferences: `{prefs: {key: string}, onboardedAt}` — row `u<id>` in server mode (by the cookie, `401` without one), `local` in local mode; the request never names an account |
| `PUT /api/me/prefs` `{prefs: {key: string \| null}}` | Merges into that row (`null` removes a key) and answers like the GET. Only the keys in `PREF_KEYS` (`server/routes/me.js`; today `agenthub.chatUi`, `"1"` = sessions open as a chat), each a string ≤ 8 KB, else `400 EBADPREFS`; same-origin |
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 and admins' account changes (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). 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 and admins' account changes (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
@@ -341,6 +343,8 @@ Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every r
Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names a session a live pane already holds — two `--resume`s of one session would be two agents writing one transcript. `error.run_id` names that pane; open it instead. A fork (`forkSession: true`) makes a new session and is allowed. A `resumeSessionId` on a catalog tool (anything but Claude/Codex) is `400 EUNSUPPORTED`, since it would silently start fresh. Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names a session a live pane already holds — two `--resume`s of one session would be two agents writing one transcript. `error.run_id` names that pane; open it instead. A fork (`forkSession: true`) makes a new session and is allowed. A `resumeSessionId` on a catalog tool (anything but Claude/Codex) is `400 EUNSUPPORTED`, since it would silently start fresh.
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently. Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive workspace action #### Preflight a destructive workspace action
@@ -1348,7 +1352,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here. - **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row. - **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`). - **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content. - **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`. - Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
@@ -1829,6 +1833,14 @@ POST /api/settings/quit
Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `<data dir>/runtime/stopped`, kills every `agenthub-lane-*` tmux session (their runs are recorded `killed`), answers `{ ok: true, killed }` and then stops the server: through `systemctl --user stop --no-block agenthub.service` / `launchctl bootout` when AgentHub's unit is running it (a SIGTERM would be restarted by `Restart=always`/`KeepAlive`), else by SIGTERM. While the flag exists the plugin bootstrap starts no server; every server clears it when it starts listening, so a boot/login through an enabled autostart unit brings the dashboard back. Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `<data dir>/runtime/stopped`, kills every `agenthub-lane-*` tmux session (their runs are recorded `killed`), answers `{ ok: true, killed }` and then stops the server: through `systemctl --user stop --no-block agenthub.service` / `launchctl bootout` when AgentHub's unit is running it (a SIGTERM would be restarted by `Restart=always`/`KeepAlive`), else by SIGTERM. While the flag exists the plugin bootstrap starts no server; every server clears it when it starts listening, so a boot/login through an enabled autostart unit brings the dashboard back.
#### Uninstall AgentHub
```http
POST /api/settings/uninstall
```
Settings → System's **Uninstall AgentHub** (same-origin only). Body `{ "confirm": true }` — anything else is `400 ECONFIRM`; in server mode `403 ESERVERMODE` (a shared dashboard is removed with `agenthub uninstall` on the host). Spawns `scripts/uninstall.js --yes` detached and answers `202 { ok: true, log }` at once; the worker then stops this server and removes everything `agenthub uninstall --yes` does (see [CLI.md](CLI.md)), the database included. `log` is `$TMPDIR/agenthub-uninstall-<uid>.log`, the only record left afterwards.
--- ---
### Tools ### Tools
@@ -2661,6 +2673,18 @@ A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots. The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
### Daily database backup
```
GET /api/settings/backup { dir, hour, keep, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep } }
PUT /api/settings/backup { dir?, hour?, keep? } → the same state; dir "" turns backups off
POST /api/settings/backup/run 202 — started (or already running); poll GET while `running`
```
Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
Restoring is manual, on purpose: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db` (its path is on Settings → System), `agenthub start`.
--- ---
## WebSocket API ## WebSocket API
@@ -2841,7 +2865,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames #### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub workspace shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed #### cc_config_changed
+1
View File
@@ -101,6 +101,7 @@ 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 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 `agenthub stop` hint. 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). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` | | `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 `agenthub stop` hint. 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). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
| `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `<data dir>/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag | | `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `<data dir>/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag |
| `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/<name>.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-<uid>.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. |
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running | | `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) | | `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 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) |
+4 -26
View File
@@ -1004,6 +1004,10 @@ function validateSession(session) {
## Backup Strategies ## Backup Strategies
### Built-in daily backup (Settings → Data → Daily backup)
Set a folder — best on another disk — and the server writes `agenthub-YYYYMMDD-HHMMSS.db` there once a day at a chosen local hour (default 02:00), keeping the newest 7 (`server/lib/db-backup.js`). It is the `VACUUM INTO` below, run on a read-only connection in a worker thread, so the dashboard keeps serving during the copy. Rotation deletes only those exact file names. To restore: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db`, `agenthub start`. Routes and settings: [API.md → Daily database backup](./API.md#daily-database-backup).
### Online Backup (Recommended) ### Online Backup (Recommended)
```sql ```sql
@@ -1025,32 +1029,6 @@ cp /var/lib/agenthub/dashboard.db /backups/dashboard_$(date +%Y%m%d).db
systemctl start agenthub systemctl start agenthub
``` ```
### Backup Schedule
```mermaid
graph TB
subgraph "Automated Backups"
Daily[Daily Backup<br/>2 AM UTC]
Weekly[Weekly Backup<br/>Sunday 2 AM]
Monthly[Monthly Backup<br/>1st of month]
end
subgraph "Retention"
Daily --> R7[Keep 7 days]
Weekly --> R4[Keep 4 weeks]
Monthly --> R12[Keep 12 months]
end
subgraph "Storage"
R7 --> Local[Local Disk]
R4 --> S3[AWS S3]
R12 --> Glacier[AWS Glacier]
end
style Daily fill:#3B82F6
style S3 fill:#FF9900
```
--- ---
## Summary ## Summary
+174 -416
View File
@@ -89,26 +89,19 @@ graph TB
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Claude as Claude Code participant Claude as Claude Code
participant HookScript as Hook Script<br/>(Python)
participant Handler as hook-handler.js participant Handler as hook-handler.js
participant Server as Dashboard Server participant Server as Dashboard Server
participant DB as SQLite participant DB as SQLite
participant WS as WebSocket participant WS as WebSocket
Claude->>HookScript: Execute (stdin: JSON) Claude->>Handler: node hook-handler.js <HookType> (stdin: JSON)
HookScript->>HookScript: Read stdin Handler->>Handler: Wrap stdin as {hook_type, data}
HookScript->>Handler: exec node hook-handler.js Handler->>Server: HTTP POST /api/hooks/event (one per dashboard database)
Handler->>Handler: Parse JSON Handler-->>Claude: exit 0 once the body is sent (never waits for the reply)
Handler->>Server: HTTP POST /hooks/{type} Server->>DB: Insert/Update (one transaction)
Server->>DB: Insert/Update data Server->>WS: Broadcast session_updated / agent_updated / new_event …
DB-->>Server: Success
Server->>WS: Broadcast event Note over Claude: Continues as soon as<br/>the handler exits
WS-->>Server: Sent to clients
Server-->>Handler: 200 OK
Handler-->>HookScript: exit 0
HookScript-->>Claude: exit 0 (non-blocking)
Note over Claude: Continues execution<br/>without waiting
``` ```
> **Security:** the hook handler POSTs to the loopback dashboard (`127.0.0.1:<port>`). > **Security:** the hook handler POSTs to the loopback dashboard (`127.0.0.1:<port>`).
@@ -130,9 +123,9 @@ graph TB
end end
subgraph "Implementation" subgraph "Implementation"
Timeout[5s Timeout] Timeout[2 s request timeout<br/>2.5 s hard exit]
ErrorLog[Silent Error Logging] ErrorLog[Always exit 0<br/>errors are dropped]
Async[Async HTTP POST] Async[Fire-and-forget POST<br/>no wait for the reply]
JSON[JSON Serialization] JSON[JSON Serialization]
end end
@@ -189,79 +182,58 @@ npm run install-hooks
> (Escape hatch for running Claude Code *inside* the same container: > (Escape hatch for running Claude Code *inside* the same container:
> `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.) > `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.)
This copies hook scripts from `scripts/hooks/` to `.githooks/`: For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly.
```mermaid ```json
graph LR {
Source[scripts/hooks/*.py] -->|Copy| Target[.githooks/*.py] "hooks": {
Handler[scripts/hook-handler.js] -->|Reference| Target "PreToolUse": [
{
Target -->|chmod +x| Executable[Executable Hooks] "matcher": "*",
"hooks": [
style Source fill:#3B82F6 { "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse" }
style Executable fill:#10B981 ]
``` }
],
### Manual Installation "SessionStart": [
{
```bash "hooks": [
#!/bin/bash { "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" SessionStart" }
# scripts/install-hooks.js ]
}
HOOKS_DIR=".githooks" ]
SOURCE_DIR="scripts/hooks" }
}
# Create hooks directory
mkdir -p "$HOOKS_DIR"
# Copy hook scripts
cp "$SOURCE_DIR/session-start.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/pre-tool-use.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/post-tool-use.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/stop.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/subagent-stop.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/notification.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/session-end.py" "$HOOKS_DIR/"
# Make executable
chmod +x "$HOOKS_DIR"/*.py
echo "Hooks installed in .githooks/"
``` ```
### Verification ### Verification
```bash ```bash
# Check hook files exist # One line per hook type with an AgentHub entry in settings.json
ls -la .githooks/ # (with the plugin installed the hooks live in its plugin.json, so this prints nothing)
grep -o 'hook-handler.js[^ ]* [A-Za-z]*' ~/.claude/settings.json
# Expected output:
# session-start.py
# pre-tool-use.py
# post-tool-use.py
# stop.py
# subagent-stop.py
# notification.py
# session-end.py
``` ```
The Settings page reports the same (`source: "plugin"` when the plugin supplies them), and `/agenthub-doctor` reports missing or duplicated entries.
--- ---
## Hook Types ## Hook Types
### 1. SessionStart ### 1. SessionStart
Triggered when a Claude Code session starts. The `source` field distinguishes the trigger: `startup` (fresh launch), `resume` (`--resume`/`--continue`), `clear` (`/clear`), and `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt. Triggered when a Claude Code session starts. The `source` field distinguishes the trigger: `startup` (fresh launch), `resume` (`--resume`/`--continue`), `clear` (`/clear`), and `fork`, and `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt.
**Payload Example:** **Payload Example:**
```json ```json
{ {
"type": "sessionStart", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"hook_event_name": "SessionStart",
"source": "startup", "source": "startup",
"model": "claude-sonnet-4", "model": "claude-opus-5-5"
"timestamp": "2024-03-18T12:00:00Z"
} }
``` ```
@@ -281,10 +253,12 @@ Triggered the moment the user hits enter on a prompt — fires *before* Claude d
```json ```json
{ {
"type": "userPromptSubmit", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"prompt": "Refactor this function...", "cwd": "/home/you/repo",
"timestamp": "2024-03-18T12:00:30Z" "permission_mode": "default",
"hook_event_name": "UserPromptSubmit",
"prompt": "Refactor this function..."
} }
``` ```
@@ -302,14 +276,19 @@ Triggered before a tool executes.
```json ```json
{ {
"type": "preToolUse", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"agentId": "agent_main_001", "cwd": "/home/you/repo",
"toolName": "bash", "permission_mode": "default",
"timestamp": "2024-03-18T12:01:00Z" "hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test", "description": "Run the tests" },
"tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv"
} }
``` ```
A tool call made inside a subagent also carries `agent_id` and `agent_type`.
**Purpose:** **Purpose:**
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; Claude can only call a tool after fresh user input) - Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; Claude can only call a tool after fresh user input)
- Set agent to `working`, set `current_tool` - Set agent to `working`, set `current_tool`
@@ -326,40 +305,36 @@ Triggered after a tool completes execution.
```json ```json
{ {
"type": "postToolUse", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"agentId": "agent_main_001", "cwd": "/home/you/repo",
"toolName": "bash", "permission_mode": "default",
"durationMs": 1234, "hook_event_name": "PostToolUse",
"success": true, "tool_name": "Bash",
"inputTokens": 1500, "tool_input": { "command": "npm test", "description": "Run the tests" },
"outputTokens": 800, "tool_response": { "stdout": "…", "stderr": "", "interrupted": false },
"timestamp": "2024-03-18T12:01:01.234Z" "tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv",
"duration_ms": 1234
} }
``` ```
The payload carries no token counts: tokens are read from the transcript (see [Transcript-derived sync](#transcript-derived-sync)).
**Purpose:** **Purpose:**
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; covers permission-prompt approval mid-tool) - Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; covers permission-prompt approval mid-tool)
- Clear `current_tool` on agent (agent stays `working`) - Clear `current_tool` on agent (agent stays `working`)
- Update agent token counts via shared transcript cache - Sync the session's tokens from its transcript, like every event that carries a `transcript_path`
- Calculate and update cost
- Rollup cost to session
**Cost Calculation Flow:** **Token and cost flow:** no cost is stored. Tokens land in `token_usage`, one row per session and model bucket, and cost is computed when it is read (`calculateCost`, e.g. `GET /api/pricing/cost`) from those rows and the editable `model_pricing` table.
```mermaid ```mermaid
graph TB graph TB
PostHook[PostToolUse Hook] --> Tokens{Has Token<br/>Counts?} Hook[Any hook with transcript_path] --> Cache[TranscriptCache<br/>reads only the new bytes]
Tokens -->|Yes| Pricing[Fetch Pricing Rule] Cache --> Usage[token_usage<br/>per session + model bucket]
Tokens -->|No| Skip[Skip Cost Update] Usage --> Read[Cost computed on read<br/>token_usage × model_pricing]
Pricing --> Calculate[Cost = <br/>input/1M * input_price +<br/>output/1M * output_price] style Usage fill:#10B981
Calculate --> UpdateAgent[Update agent.cost] style Read fill:#3B82F6
UpdateAgent --> Rollup[Rollup to session.total_cost]
Rollup --> Broadcast[Broadcast Updates]
style Calculate fill:#10B981
style Broadcast fill:#F59E0B
``` ```
--- ---
@@ -372,10 +347,13 @@ Triggered when Claude finishes a turn (NOT when the session is closed).
```json ```json
{ {
"type": "stop", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"stop_reason": "end_turn", "cwd": "/home/you/repo",
"timestamp": "2024-03-18T12:05:00Z" "permission_mode": "default",
"hook_event_name": "Stop",
"stop_hook_active": false,
"last_assistant_message": "Done — the tests pass."
} }
``` ```
@@ -396,11 +374,16 @@ Triggered when a sub-agent (explore, task, etc.) completes.
```json ```json
{ {
"type": "subagentStop", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"agentId": "agent_explore_002", "cwd": "/home/you/repo",
"agentType": "explore", "permission_mode": "default",
"timestamp": "2024-03-18T12:03:00Z" "hook_event_name": "SubagentStop",
"agent_id": "a73b0d3904535a49c",
"agent_type": "Explore",
"agent_transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01/subagents/agent-a73b0d3904535a49c.jsonl",
"stop_hook_active": false,
"last_assistant_message": "Found 3 call sites…"
} }
``` ```
@@ -422,14 +405,17 @@ Triggered when Claude Code sends a system notification.
```json ```json
{ {
"type": "notification", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"notificationType": "backgroundTaskComplete", "cwd": "/home/you/repo",
"message": "Explore agent completed successfully", "hook_event_name": "Notification",
"timestamp": "2024-03-18T12:03:00Z" "message": "Claude needs your permission to use Bash",
"notification_type": "permission_prompt"
} }
``` ```
Other `notification_type` values seen: `idle_prompt`, `agent_completed`, `auth_success`.
**Purpose:** **Purpose:**
- Log the event for the activity feed - Log the event for the activity feed
- If the message matches a permission/input-prompt pattern (`permission`, `waiting for input`, `needs your approval`, `awaiting your response`, …), stamp `awaiting_input_since` (with `awaiting_reason` = `notification`) so the session lands in **Waiting** - If the message matches a permission/input-prompt pattern (`permission`, `waiting for input`, `needs your approval`, `awaiting your response`, …), stamp `awaiting_input_since` (with `awaiting_reason` = `notification`) so the session lands in **Waiting**
@@ -446,12 +432,16 @@ Triggered when a Claude Code session ends.
```json ```json
{ {
"type": "sessionEnd", "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"sessionId": "sess_abc123", "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"timestamp": "2024-03-18T14:30:00Z" "cwd": "/home/you/repo",
"hook_event_name": "SessionEnd",
"reason": "prompt_input_exit"
} }
``` ```
`reason` is one of `prompt_input_exit`, `clear`, `resume`, `other`.
**Purpose:** **Purpose:**
- Drop `awaiting_input_since` (and `awaiting_reason`, cleared to NULL together) on the session and any agents that still have it - Drop `awaiting_input_since` (and `awaiting_reason`, cleared to NULL together) on the session and any agents that still have it
- Mark all agents and the session as `completed` — **unless the session is in `error` AND that error is still unrecovered at the transcript tail** (`isErrorAtTail`: the latest API error has no successful turn after it), in which case `error` is preserved. A transient error the CLI retried past (successful assistant turns after the last error) finalizes as `completed` instead of freezing in a stale `error` - Mark all agents and the session as `completed` — **unless the session is in `error` AND that error is still unrecovered at the transcript tail** (`isErrorAtTail`: the latest API error has no successful turn after it), in which case `error` is preserved. A transient error the CLI retried past (successful assistant turns after the last error) finalizes as `completed` instead of freezing in a stale `error`
@@ -467,120 +457,29 @@ Triggered when a Claude Code session ends.
```mermaid ```mermaid
graph TB graph TB
CLI[CLI Args] --> Parse[Parse Hook Type] Args[argv: HookType + optional tool] --> Wrap
Stdin[stdin] --> ReadJSON[Read JSON] Stdin[stdin JSON] --> Wrap[Envelope<br/>hook_type, data, tool?, run_id?]
Wrap --> Ports[resolveHookIngestPorts<br/>one port per database]
Parse --> HookType{Hook Type?} Ports --> P1[POST /api/hooks/event<br/>dashboard A]
ReadJSON --> Payload[Event Payload] Ports --> P2[POST /api/hooks/event<br/>dashboard B]
P1 --> Flushed[Request body sent]
HookType -->|session-start| Endpoint1[POST /hooks/session-start] P2 --> Flushed
HookType -->|pre-tool-use| Endpoint2[POST /hooks/pre-tool-use] Flushed --> Exit[exit 0]
HookType -->|post-tool-use| Endpoint3[POST /hooks/post-tool-use] Guard[2.5 s safety timer] --> Exit
HookType -->|stop| Endpoint4[POST /hooks/stop]
HookType -->|subagent-stop| Endpoint5[POST /hooks/subagent-stop] style Exit fill:#10B981
HookType -->|notification| Endpoint6[POST /hooks/notification]
HookType -->|session-end| Endpoint7[POST /hooks/session-end]
Payload --> Endpoint1
Payload --> Endpoint2
Payload --> Endpoint3
Payload --> Endpoint4
Payload --> Endpoint5
Payload --> Endpoint6
Payload --> Endpoint7
Endpoint1 --> HTTP[HTTP POST]
Endpoint2 --> HTTP
Endpoint3 --> HTTP
Endpoint4 --> HTTP
Endpoint5 --> HTTP
Endpoint6 --> HTTP
Endpoint7 --> HTTP
HTTP --> Response{Success?}
Response -->|Yes| Exit0[exit 0]
Response -->|No| Exit1[exit 1]
style HTTP fill:#10B981
style Exit0 fill:#10B981
style Exit1 fill:#EF4444
``` ```
### Implementation ### What it does
```javascript `scripts/hook-handler.js` (about 120 lines) is the whole forwarder:
#!/usr/bin/env node
// scripts/hook-handler.js
const http = require('http'); - **Envelope.** It POSTs `{hook_type, data, tool?, run_id?}` to `/api/hooks/event`. `hook_type` is its first argument; `data` is the parsed stdin, or `{raw: "<stdin>"}` when stdin is not JSON. `tool` is `codex` or `antigravity` when those tools' own hook scripts pass it as the second argument (Claude Code passes none). `run_id` is `AGENTHUB_RUN_ID`, set in every pane the dashboard starts.
const fs = require('fs'); - **Fire-and-forget.** It exits once the request body is sent, without waiting for the dashboard's reply, so a busy or wedged dashboard never holds Claude Code at "running hooks".
- **Never fails.** A refused connection is skipped, a request that takes over 2 s is dropped, and a 2.5 s timer exits regardless. The exit code is always 0.
const HOOK_TYPE = process.argv[2]; > **Port resolution & fan-out.** The handler resolves its targets at runtime
const SERVER_URL = 'http://localhost:4820'; > via `server/lib/server-info.js`:
const TIMEOUT = 5000; // 5s timeout
// Read JSON from stdin
let inputData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => inputData += chunk);
process.stdin.on('end', () => {
try {
const payload = JSON.parse(inputData);
sendToServer(HOOK_TYPE, payload);
} catch (err) {
console.error('[hook-handler] JSON parse error:', err);
process.exit(1);
}
});
function sendToServer(hookType, payload) {
const postData = JSON.stringify(payload);
const options = {
hostname: 'localhost',
port: 4820,
path: `/hooks/${hookType}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
},
timeout: TIMEOUT
};
const req = http.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => responseData += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
process.exit(0);
} else {
console.error(`[hook-handler] Server error: ${res.statusCode}`);
process.exit(1);
}
});
});
req.on('error', (err) => {
console.error('[hook-handler] Request error:', err);
process.exit(1);
});
req.on('timeout', () => {
console.error('[hook-handler] Request timeout');
req.destroy();
process.exit(1);
});
req.write(postData);
req.end();
}
```
> **Port resolution & fan-out.** The snippet above shows a single fixed `4820`
> for clarity. The real `scripts/hook-handler.js` resolves hook targets at
> runtime via `server/lib/server-info.js`:
> >
> 1. If `CLAUDE_DASHBOARD_PORT` is set in the environment, the handler treats > 1. If `CLAUDE_DASHBOARD_PORT` is set in the environment, the handler treats
> it as an explicit operator override and POSTs to that single port — > it as an explicit operator override and POSTs to that single port —
@@ -606,38 +505,13 @@ function sendToServer(hookType, payload) {
### Server-Side Hook Processing ### Server-Side Hook Processing
```javascript `POST /api/hooks/event` (`server/routes/hooks.js`, `ingestHook`):
// server/routes/hooks.js
router.post('/session-start', (req, res) => { 1. **Checks the body** (400 when it is not a hook), maps a Codex or Antigravity payload onto Claude's shape, and answers `{ok: true, ignored: true}` for a hook from an ignored folder and `{ok: true, duplicate: true}` for the same event posted twice within 10 s.
try { 2. **Reads the transcript first** (incremental `TranscriptCache`), so the write transaction never waits on a large file.
const { sessionId, model, agentId, agentType } = req.body; 3. **Runs `processEvent`** — session and agent upserts, the status transitions described under [Hook Types](#hook-types), the event row (redacted when Redact secrets is on) and the transcript-derived sync below — then binds the session to the workspace pane that ran it (`run_id`).
4. **Broadcasts** the changes over WebSocket: `session_created`, `session_updated`, `agent_created`, `agent_updated`, `new_event`, `workflow_upserted`.
// Upsert session 5. **After the reply**, evaluates event-driven alert rules, and a `SubagentStop` starts the subagent JSONL scan.
let session = stmts.findSession.get(sessionId);
if (!session) {
stmts.createSession.run(sessionId, model);
session = stmts.findSession.get(sessionId);
broadcast({ type: 'session.created', data: session });
}
// Create main agent
if (!stmts.findAgent.get(agentId)) {
stmts.createAgent.run(agentId, sessionId, agentType);
const agent = stmts.findAgent.get(agentId);
broadcast({ type: 'agent.created', data: agent });
}
// Touch session (update updated_at)
stmts.touchSession.run(sessionId);
res.json({ success: true });
} catch (err) {
console.error('session-start error:', err);
res.json({ success: false, error: err.message });
}
});
```
### Event Processing Pipeline ### Event Processing Pipeline
@@ -691,64 +565,17 @@ Fail-safe guards: the probe reports "no answer" (nothing changes) on Windows, in
### Error Handling Strategy ### Error Handling Strategy
```mermaid The handler has no error path that reaches Claude Code:
graph TB
Error[Error Occurs] --> Type{Error Type?}
Type -->|Network Error| Retry[Retry Once]
Type -->|Timeout| Log1[Log + Exit 1]
Type -->|Parse Error| Log2[Log + Exit 1]
Type -->|Server Error| Log3[Log + Exit 1]
Retry --> Success{Success?}
Success -->|Yes| Exit0[Exit 0]
Success -->|No| Exit1[Exit 1]
Log1 --> Exit1
Log2 --> Exit1
Log3 --> Exit1
style Exit0 fill:#10B981
style Exit1 fill:#EF4444
```
### Hook Script Error Handling | What goes wrong | What the handler does |
|-----------------|-----------------------|
| stdin is not JSON | Forwards it as `data: {raw: "<stdin>"}` |
| No dashboard listening (`ECONNREFUSED`) | Skips that target |
| A dashboard does not take the request within 2 s | Drops it and moves on |
| Anything hangs | The 2.5 s safety timer exits |
| The dashboard rejects or fails the event | Not seen — the handler never reads the reply |
```python It always exits 0, so Claude Code never shows a hook error and never waits on AgentHub. Events sent while no dashboard was running are not replayed; the transcript sync and the [liveness reap](#missed-sessionend-dashboard-down--liveness-reap) bring the session up to date later.
#!/usr/bin/env python3
# .githooks/session-start.py
import sys
import json
import subprocess
import logging
logging.basicConfig(
filename='.githooks/hooks.log',
level=logging.ERROR,
format='%(asctime)s - %(levelname)s - %(message)s'
)
try:
data = json.load(sys.stdin)
result = subprocess.run(
['node', 'scripts/hook-handler.js', 'session-start'],
input=json.dumps(data),
text=True,
timeout=5,
capture_output=True
)
if result.returncode != 0:
logging.error(f'Hook handler failed: {result.stderr}')
except Exception as e:
logging.error(f'Hook error: {str(e)}')
# Always exit 0 to avoid blocking Claude Code
sys.exit(0)
```
--- ---
@@ -756,55 +583,14 @@ sys.exit(0)
### Hook Execution Time ### Hook Execution Time
```mermaid Claude Code waits only for the handler process: Node start-up, reading stdin, sending the request. Measured on a 4-core desktop: **60–90 ms per hook**, almost all of it Node start-up. The dashboard's work happens after the handler has exited — ingesting a hook took ~2 ms (p50) against a 1.25 GB database at 10 hooks/s, and stayed under 40 ms (p99) at 100 hooks/s with 10 browsers open.
graph TB
subgraph "Hook Execution Breakdown"
Python[Python Script<br/>~10ms]
Handler[Node Handler<br/>~20ms]
HTTP[HTTP POST<br/>~30ms]
DB[Database Write<br/>~5ms]
WS[WebSocket Broadcast<br/>~5ms]
end
Total[Total: ~70ms]
Python --> Handler
Handler --> HTTP
HTTP --> DB
DB --> WS
WS --> Total
style Total fill:#10B981
```
**Performance Targets:** ### What keeps it fast
| Phase | Target | Actual | - **Fire-and-forget POST** — no wait for the dashboard's reply.
|-------|--------|--------| - **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
| Hook script | < 20ms | ~10ms | - **One POST per database, not per server** — servers sharing a database get each event once (`resolveHookIngestPorts`).
| Handler | < 30ms | ~20ms | - **Heavy reads off the event loop** — aggregates run in a worker (`server/lib/read-pool.js`), so a slow page never delays ingest.
| HTTP POST | < 50ms | ~30ms |
| Database | < 10ms | ~5ms |
| **Total** | **< 100ms** | **~70ms** |
### Optimization Techniques
```mermaid
graph TB
subgraph "Optimizations"
Async[Async HTTP<br/>Don't wait for response]
Batch[Batch Updates<br/>Transaction batching]
Index[Database Indexes<br/>Fast lookups]
Pool[Connection Pooling<br/>Reuse connections]
end
Async --> Faster[Faster Hook Execution]
Batch --> Faster
Index --> Faster
Pool --> Faster
style Faster fill:#10B981
```
--- ---
@@ -812,49 +598,27 @@ graph TB
### Manual Testing ### Manual Testing
Pipe a payload into the handler. It posts to every running dashboard, so use a session id you will recognise:
```bash ```bash
# Test session-start hook echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"SessionStart","source":"startup"}' \
echo '{"type":"sessionStart","sessionId":"test_001","model":"claude-sonnet-4","agentId":"agent_test","agentType":"general-purpose"}' | \ | node scripts/hook-handler.js SessionStart
python3 .githooks/session-start.py
# Test pre-tool-use hook echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"},"tool_use_id":"toolu_test_1"}' \
echo '{"type":"preToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash"}' | \ | node scripts/hook-handler.js PreToolUse
python3 .githooks/pre-tool-use.py ```
# Test post-tool-use hook The handler prints nothing. To see the dashboard's answer, POST the envelope yourself:
echo '{"type":"postToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash","durationMs":100,"success":true,"inputTokens":1000,"outputTokens":500}' | \
python3 .githooks/post-tool-use.py ```bash
curl -s -X POST http://localhost:4820/api/hooks/event \
-H 'Content-Type: application/json' \
-d '{"hook_type":"Stop","data":{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"Stop"}}'
``` ```
### Integration Testing ### Integration Testing
```javascript The server suite drives `POST /api/hooks/event` end to end — for example `server/__tests__/api.test.js` and `server/__tests__/awaiting-subagent-guard.test.js`; Codex and Antigravity hooks have `codex-hook-route.test.js` and `antigravity-hook-route.test.js`. Run them with `npm run test:server`, never a bare `node --test` from a workspace pane (some suites start and kill tmux sessions).
// server/__tests__/hooks.test.js
import { test } from 'node:test';
import assert from 'node:assert';
test('session-start hook creates session', async () => {
const payload = {
sessionId: 'test_session',
model: 'claude-sonnet-4',
agentId: 'test_agent',
agentType: 'general-purpose'
};
const response = await fetch('http://localhost:4820/hooks/session-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
assert.strictEqual(data.success, true);
// Verify session exists
const session = await fetch('http://localhost:4820/api/sessions/test_session');
assert.strictEqual(session.status, 200);
});
```
--- ---
@@ -864,29 +628,23 @@ test('session-start hook creates session', async () => {
| Issue | Symptoms | Solution | | Issue | Symptoms | Solution |
|-------|----------|----------| |-------|----------|----------|
| Hooks not executing | No data in dashboard | Check `.githooks/` exists and scripts are executable | | Hooks not installed | No data in dashboard | `grep hook-handler.js ~/.claude/settings.json` (or check the plugin is enabled), then `npm run install-hooks` or `/agenthub-doctor` |
| Timeout errors | Hooks take >5s | Check server is running, reduce timeout | | Hooks installed twice | Every event doubled | Plugin and settings.json both carry them — see [Installation Script](#installation-script) |
| Parse errors | JSON parse failed | Validate hook payload format | | Dashboard not running | Hooks run, nothing arrives | `agenthub start` (or `npm start` from a checkout); events sent meanwhile are not replayed, the transcript sync fills the session in |
| Permission denied | Hook script won't run | `chmod +x .githooks/*.py` | | Dashboard on another port | Nothing arrives | The handler finds servers through `~/.claude/.agenthub.json`; set `CLAUDE_DASHBOARD_PORT` to force one |
| Server connection refused | HTTP POST fails | Start dashboard server (`npm start`) | | Hooks installed from a container | `MODULE_NOT_FOUND` in Claude Code | Install on the host — see the container note under [Installation Script](#installation-script) |
### Debug Mode ### Seeing what a hook sends
```bash The handler logs nothing by design. POST the envelope yourself (see [Manual Testing](#manual-testing)) and read the reply.
# Enable hook logging
export DASHBOARD_DEBUG=1
# Run hook manually with verbose output
python3 -u .githooks/session-start.py < test-payload.json
```
### Health Check ### Health Check
```bash ```bash
# Check server is running # Check server is running
curl http://localhost:4820/api/sessions curl http://localhost:4820/api/health
# Expected: {"sessions": [...]} # Expected: {"status":"ok","version":"…","timestamp":"…"}
``` ```
--- ---
+6 -5
View File
@@ -391,9 +391,10 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers. - **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`). - **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet). - **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split in the tab on screen (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace"). - **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from. - **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **Chat view of a live pane** — with **Settings → Appearance → Chat UI** on (`ChatUiSettings.tsx`, one choice for every pane, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`, `client/src/lib/chatUi.ts`, so each user of a shared dashboard keeps their own on any browser), a Claude or Codex pane whose session is known shows a chat instead of its terminal (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls and thinking folded into one line named by what it did ("Ran 3 commands, edited a file ›", `workLabel`) that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. A permission prompt or a menu still needs the terminal — turn Chat UI off to answer it. The ended-session replay and the Analyst page's conversation use the same layout.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it. - **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed. - **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly. - **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
@@ -407,9 +408,9 @@ 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). 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. - **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). - **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. 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.) - **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, in the same order as a join below (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) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; 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, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. 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. - **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 at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (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, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. 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. - **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.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`. Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
@@ -693,7 +694,7 @@ reads the workspace's git surface in five modes:
user presses Enter in the pane; with no pane of the workspace open the box user presses Enter in the pane; with no pane of the workspace open the box
says so and keeps the text. says so and keeps the text.
In the file editor, a text selection shows Cursor's **Add to Chat** chip In the file editor, a text selection shows Cursor's **Add to Chat** chip
above it, which pastes `@<path>#L<from>-<to>` (Claude Code's line-range at the end of its first line (inline, covering no code), which pastes `@<path>#L<from>-<to>` (Claude Code's line-range
mention; `#L<n>` for one line — without the `L` Claude Code attaches the mention; `#L<n>` for one line — without the `L` Claude Code attaches the
whole file) the same way. Other tools (Codex, …) get the same text as a whole file) the same way. Other tools (Codex, …) get the same text as a
plain hint. plain hint.
@@ -1809,7 +1810,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool ## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set. A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file: A catalog file:

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