Compare commits

...

38 Commits

Author SHA1 Message Date
nntrivi2001 0547488f13 chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files
- Prompt: attach a file on this computer by path (native picker), clearer too-large messages
- Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills
- Files: batch upload of small files in one extract
- AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
2026-10-20 16:44:55 +07:00
nntrivi2001 49f87c4da3 chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
2026-10-19 17:19:41 +07:00
nntrivi2001 1a6b550e89 feat(files): New → Clone repository clones into the open folder
The File Explorer's New menu (and its right-click menu) gains Clone
repository: it clones a URL into the folder it has open — of the user's own
home or of a workspace's own folder — through POST /api/lanes/clone with
that folder as `parent`, so the URL allowlist, the own-folder check and the
folder account's own git identity all apply, and opens a workspace on the
clone. ECREDENTIALS asks for a login, ESSHAUTH points to Settings → Git.
GET /api/homes now gives `me` its absolute `dir`. Keys typed in the dialog
never reach the explorer's shortcuts (Backspace / Delete).
2026-10-19 17:13:26 +07:00
nntrivi2001 f8d07dab61 feat(ai-tools): omp shared + OpenCode/omp sessions, tokens, subagents; Grok hooks; resume by id
- omp-usage.js / opencode-usage.js: poll each CLI's own session store into
  sessions (tool omp/opencode), token_usage per model and subagent agents;
  transcript routes read them (omp journals, OpenCode's opencode.db).
- Grok runs the Claude hooks in ~/.claude/settings.json: its hooks are now
  recognised (hookEventName + workspaceRoot), tagged tool=grok, and their
  transcript_path dropped instead of being parsed as a Claude transcript.
- Catalog `resumeById`: omp/Grok/Cursor `--resume <id>`, OpenCode
  `--session <id>`; session ids may carry `_`. The client offers Resume for
  any tool that resumes by id.
- Kimi Code and GitHub Copilot leave the catalog (their session stores could
  not be verified).
2026-10-19 16:49:59 +07:00
nntrivi2001 0434ffc750 fix(tmux): scrolling a pane no longer freezes typing
The wheel puts a pane into tmux's copy-mode, which took every typed key as
a scrollback command and painted a yellow [0/0] box in the corner. In
AgentHub's own panes (every other session on the server keeps tmux's
defaults, restored verbatim):

- the copy-mode position box is off (copy-mode-position-format "");
- the first keystroke after a mouse report leaves copy-mode
  (`copy-mode -q`, pty-attach) before it is written, once per scroll;
- copy-mode keys that open a tmux prompt (g, :, /, f, digits…) leave
  copy-mode and reach the program;
- the wheel scrolls 10 lines a notch instead of 5, and the first notch
  already scrolls instead of only entering copy-mode.
2026-10-19 11:59:22 +07:00
nntrivi2001 e2d8d314f6 feat(workspace): check the git identity before cloning extra folders
The workspace's "Or clone repositories" box now reads GET /api/me/git and
checks every URL as it is typed: an SSH link while the account has no SSH
key is never sent and points to Settings → Git (make a key, add its public
key to the host); an HTTPS host with no saved login is flagged with an
optional login row, since a public repo needs none. The identity is re-read
when the window regains focus, and a refused SSH login links to the same
settings. The server's ECREDENTIALS / ESSHAUTH answers still decide.
2026-10-19 11:54:49 +07:00
nntrivi2001 0c8782ea75 chore: v2.8.11 — switch a pool account's users from its Edit 2026-10-19 11:03:54 +07:00
nntrivi2001 c042adc51b ui(pool): pick an account's users from its own Edit
Each account row in the Account pool card has a pencil: tick the users
whose sessions run on it (one moves off any other account, unticking sends
one back to the balancer). PUT /api/accounts/:name/users saves the set in one
transaction, audits each change and switches a Claude login into each added
user's home at once. The card-level switch button and pinned list are gone.
2026-10-19 10:45:54 +07:00
nntrivi2001 776ad6466f feat(pool): an admin's switch moves a user's Claude login at once
PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned
Claude login into the user's home right away (claude-pool.switchHome):
every home's newer pair is copied back first, the member's own login is
never written over, and no pane is restarted — running sessions take the
new pair at their next credentials read. A live home stays on the login it
holds, so unpinning does not flip it back. The Users page calls it
"Switch a user's account" and says what happened.
2026-10-19 10:23:44 +07:00
nntrivi2001 9c98638ec0 feat(pool): pin a user to one Claude/Codex pool account
An admin pins a server-mode user to one pool account per tool
(account_pins; PUT /api/accounts/pins with the password step-up and audit,
the Users page's Pin a user, agenthub accounts pin|unpin). The workspace
owner's pin wins over the balancer whenever the pane may use the account;
a pinned login still waits while another pool login holds the home.
2026-10-19 10:05:19 +07:00
nntrivi2001 aa8ac6cad9 fix: v2.8.10 — the server starts again on plugin installs (js-yaml is a runtime dependency)
v2.8.9 crashed at start on every plugin install with "Cannot find module
'js-yaml'": server/lib/lane-detect.js requires it, but it was only a
devDependency that redoc used to pull in, and v2.8.8 dropped redoc. The
plugin installs with --omit=dev. This box hid it: Debian's global
/usr/share/nodejs has js-yaml. Reproduced with a pruned install and the
global paths off; fixed by moving js-yaml (and argparse in the lock) to
dependencies. New runtime-deps.test.js fails on any server/ require that is
not a dependency.
2026-10-16 14:54:43 +07:00
nntrivi2001 3237ce4774 chore: v2.8.9 — /tui works in panes with workspace instructions; clearer Startup buttons 2026-10-16 13:39:31 +07:00
nntrivi2001 7c5f30df7d fix(panes): /tui works in panes with workspace instructions
Claude refused `/tui` ("Cannot switch renderers in this session — it has
restrictions a restart can't carry over") in every pane started with
--append-system-prompt-file, i.e. any workspace with instructions (member
panes usually have the owner's). The instructions are now written as the only
CLAUDE.md of a folder of their own (<data dir>/workspace-prompts/<id>/, a
member's prompt-<id>/ in the handoff dir via write-as), passed as one more
--add-dir with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1. Verified with
Claude Code 2.1.301: the file is listed in /memory and /tui switches. Codex
is unchanged (its pointer line names the same file).
2026-10-16 13:38:09 +07:00
nntrivi2001 5e0a9b526b ui(settings): Quit/Uninstall as real buttons with icons and a busy spinner; code in row descriptions; dimmed unavailable rows; smoother switch 2026-10-16 10:58:14 +07:00
nntrivi2001 538aec3ed9 chore: v2.8.8 — faster updates and lighter local runs, images attach from the chat on Windows, pet chat window 2026-10-16 10:28:58 +07:00
nntrivi2001 692f532972 Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest 2026-10-16 10:27:26 +07:00
nntrivi2001 f460fff406 feat: pet chat window replaces the Sagi panel; Windows pane attach no longer crashes the server
- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo
  status strip, Needs you, Mute / Clear alerts); SagiPanel removed.
- Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu).
- pty-attach: resize/write after a Windows attach exited, and node-pty's pipe
  errors, no longer throw out of the WebSocket handler.
- restart-helper: the server started by an update logs to
  data/agenthub-server.log instead of discarding its output.
2026-10-16 10:27:22 +07:00
nntrivi2001 2c712b06d1 test: vendored files out of the link lint, whole-second mtime in codex settle test; regenerate openapi.yaml 2026-10-16 10:25:32 +07:00
nntrivi2001 1b09371042 Merge branch 'perf/local-dbupd' into perf/local-opt 2026-10-16 10:20:59 +07:00
nntrivi2001 14595651fe Merge branch 'perf/local-ingest' into perf/local-opt 2026-10-16 10:20:59 +07:00
nntrivi2001 8739294a72 Merge branch 'perf/local-install' into perf/local-opt 2026-10-16 10:20:59 +07:00
nntrivi2001 e07993c536 perf: hourly update check only with a browser open; opt-in retention, incremental auto-vacuum and Compact
- update-scheduler: default interval 1 h (DASHBOARD_UPDATE_CHECK_INTERVAL_MS and its 60 s floor kept);
  a scheduled tick with no /ws client is skipped, and the next browser to connect gets one catch-up
  check when the last one is stale (websocket.js onConnection). Manual check unchanged.
- db-retention.js: shared session purge (used by POST /api/settings/cleanup too), opt-in
  AGENTHUB_RETENTION_DAYS applied by the hourly maintenance in 25-session transactions with yields
  (max 5000/run), bounded incremental_vacuum when the freelist is large, and a manual Compact
  (VACUUM in a worker, converting to auto_vacuum=INCREMENTAL).
- db.js: new databases are INCREMENTAL; an existing one up to 32 MB is converted at startup.
- GET/PUT /api/settings/retention, POST /api/settings/compact (confirm: true); Settings → Data →
  Session cleanup gets the Automatic cleanup control and Compact now (en + vi).
2026-10-16 10:20:38 +07:00
nntrivi2001 5cb70d702b perf: cheaper ingest — no re-parse of live hooked transcripts, delta hook writes, lighter pollers
- Session sync leaves a transcript alone while hooks feed its session live
  (active, newest event a hook's, touched within DASHBOARD_SYNC_LIVE_HOOK_MS,
  default 5 min) and catches it up once quiet; a hook-created session (which
  importSession skips anyway) is only snapshotted, never parsed.
- Hook writer keeps a per-transcript mark (applied after commit): an
  unchanged TranscriptCache result skips token/compaction/API-error/turn
  writes, a grown one walks only the entries after the last one handled.
- Codex usage: one stamp query per pass instead of one row per journal;
  between full passes (first + every 5 min) only the newest day folders and
  journals written in the last hour are statted; fork parents resolved from
  the table or a full walk.
- Cursor/Antigravity polls back off to 60 s after idle passes
  (server/lib/backoff-poll.js); lane sync runs only while another dashboard
  shares the data dir (checked every 30 s).
2026-10-16 10:20:09 +07:00
nntrivi2001 9a4ed54cf7 perf: lighter, louder local install — vendored ReDoc, bootstrap failures shown in the session
- Plugin bootstrap: a failed npm install, a Node older than 22.5 or a failed UI
  build is recorded in runtime/last-failure.json; the next SessionStart prints
  it once as a systemMessage naming the log and /agenthub-doctor (a different
  failure is reported again; a successful bootstrap clears it). The foreground
  pass checks the Node version itself and spawns no worker on an old Node.
- ReDoc: the standalone bundle (redoc 2.5.4, MIT, licence files kept) is
  vendored under server/vendor/redoc/ and the `redoc` dependency dropped —
  a runtime install (npm ci --omit=dev --ignore-scripts) goes 126 MB -> 45 MB.
  vendor/ is excluded from the header audit and prettier.
- git missing from PATH: lane git actions answer 503 EGITMISSING with the
  install command instead of a bare ENOENT.
- package.json engines: node >=22.5.0 (node:sqlite), matching MIN_NODE.
- Docs: INSTALL.md is the one install/setup guide (SETUP.md merged in and now a
  pointer); no VS Code extension claim, one client-install hint, no
  windows-build-tools, better-sqlite3 notes scoped to checkouts, new
  "Plugin bootstrap never comes up" troubleshooting entry.
2026-10-16 10:16:54 +07:00
nntrivi2001 57c423cdb1 perf(client): cut needless polling for local users
- ConversationView: an ended session (SessionDetail, SessionReplay pass
  `ended`) polls its transcript every 60 s instead of 3 s and stops
  rescanning sub-agents; new_event, reconnect and show still fetch at once.
- Workspace: GET /api/run follows run-moving WS messages (lane run
  columns, session created/status change, runs_interrupted) with a 30 s
  fallback, instead of a blind 5 s poll.
- StatusBar: session counts re-read only when a session's
  status/waiting/archived state changes (60 s fallback); costs re-read at
  most every 30 s while work is happening and every 2 min otherwise.
- Dashboard recent activity: 60 s safety poll (was 10 s) plus remote
  import messages; WS still prepends new events.
- New lib/sessionChange.ts matcher with tests.
2026-10-16 10:15:26 +07:00
nntrivi2001 ecbbb4dd8f perf: faster plugin updates, half-size first bundle, images from the chat attach on Windows
- Chat view: an attached image's path is pasted on its own after the text
  (promptForPane). claude turns a paste that is only an image path into
  [Image #n], but left a Windows path inside a paste of text as a plain path.
  Other files and @-mentions stay in the text as before.
- Plugin bootstrap: depsHash covers only what npm installs (no release
  version), so a version bump no longer reinstalls runtime deps; the client's
  installed tree is kept while its manifests are unchanged (no npm ci per
  update); the user-side build runs vite only (tsc -b cost ~25 s / ~900 MB);
  clientBuild is recorded only after a build that did not fail, so a failed
  one is retried; npm output goes to runtime/npm-install.log.
- Server: a port held by another program ends with a clear message and exit 1
  instead of an uncaught 'error' stack trace.
- Client: the onboarding wizard is lazy and rendered only before onboarding;
  usePrefersReducedMotion moved to hooks/ so PetSettings no longer pulls the
  Sagi chat/pane stack into the entry chunk (884 KB -> 431 KB, gzip 268 -> 133 KB).
2026-10-16 10:05:03 +07:00
nntrivi2001 a58e95a957 chore: v2.8.7 — Git SSH keys in tabs, safer Artifacts preview and search
- Settings → Git → Your git identity: SSH keys are one tab per host kind
  (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the
  AWS tab when only an RSA key exists.
- Artifacts: a file type the sandboxed preview cannot show (Office, zip,
  PDF) offers Download only, never a blank Preview.
- Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE).
- Skill frontmatter rewrite replaces a key with its continuation lines and
  quotes values YAML could misread.
- Plugin marketplace.json is read with a 1 MB streaming cap.
- Artifact lists look each owner up once.
2026-10-15 17:17:47 +07:00
nntrivi2001 4d6067650a chore: v2.8.6 — Administration page (Users + Server tabs), right-click menus, several SSH keys per host 2026-10-15 16:45:24 +07:00
nntrivi2001 5bc3f7d34a Merge branch 'feat/admin-console': Administration page (Users + Server tabs) 2026-10-15 16:44:09 +07:00
nntrivi2001 b058179177 feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
2026-10-15 16:42:47 +07:00
nntrivi2001 b6b40b0c1b feat(admin): Administration page — Users tab + Server tab (hardware, per-user CPU/memory/home share)
/users becomes /admin (redirected), a tab shell over the existing Users view
and a new Server tab backed by GET /api/host (server/lib/host-stats.js):
hostname/OS/uptime, CPU and load, memory and swap, every real disk with
HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples —
attributed by the member's Linux uid (S3) or, for host processes, by the
inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host
in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the
Server tab.
2026-10-15 16:33:32 +07:00
nntrivi2001 758609775f chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's
  Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable
  one showing its error); a folder opens that user's Trash (path bar
  Trash › user, Up back to the folders). An admin may restore/purge in
  another user's Trash (audited); purging asks first. The merged "All users"
  list is gone from the UI.
- A home's Trash is its user's whole recycle bin (home store + host store,
  items tagged `store`).
- `agenthub server setup` installs one Linux user per account on Linux by
  default (`--no-system` opts out); install-system messages drop `sudo`.
- Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:02:11 +07:00
nntrivi2001 09efb089cf chore: v2.8.4 — member tmux follows tmux upgrades, Members' tmux card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:45:24 +07:00
nntrivi2001 c83ddf62a0 feat(server-s3): a member's tmux follows tmux upgrades — tmux-member + Users → Members' tmux
A member's tmux server keeps the version it started with until their last
pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a
3.8 client on a 3.6 server spun it at 100% CPU (three members needed a
manual kill on 2026-10-15).

- /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a
  member's tmux call with the binary of THEIR running server
  (/proc/<pid>/exe, found from the socket in /proc/net/unix), else
  TMUX_BIN. It runs as the member and execs only a process they own.
  sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN
  until then.
- Users → Members' tmux (GET /api/users/tmux): each member server's version
  beside the installed one, sessions, agents working. An admin restarts one
  (POST /api/users/:id/tmux-restart) or every idle outdated one
  (POST /api/users/tmux-restart-idle), with their password again, audited
  per member; a server that will not stop answers 409 ETMUXSTUCK naming
  its pid for root.
- server-s3-system.test.js: `delete` is a provision action since Delete
  user, so the "unknown action" case now uses `purge`.

Operators: run `sudo agenthub server install-system` once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:44:47 +07:00
nntrivi2001 d8ab850295 chore: v2.8.3 — a stuck tmux server no longer freezes the dashboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:46 +07:00
nntrivi2001 bac6c04a0b fix(tmux): bound calls on our own server and run them with the server's own binary
A tmux 3.8 installed under a running 3.6 server left PATH's 3.8 client
asking the 3.6 server, which spun at 100% CPU; the dashboard's
synchronous `has-session` on it had no limit and froze every page, hook
and terminal for five minutes (14:07–14:12 on the shared server).

- Every call on our own server now has a 5 s limit and the member's
  one-minute back-off (`host: tmux server not answering` meanwhile), so a
  stuck server costs the event loop one timeout per minute, not one per
  poll.
- Every call on a running server runs that server's own binary
  (`/proc/<pid>/exe`, found from its listening socket in /proc — asking
  tmux would be the very call that hangs), re-read every 10 s; PATH's
  tmux only starts a server or answers `-V`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:16 +07:00
nntrivi2001 38c7ac6635 chore: v2.8.2 — SSH key sections per git host, CodeCommit SSH key ID for a plain ssh:// clone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:53:32 +07:00
nntrivi2001 276cead826 feat(git identity): one section per git host; CodeCommit SSH key ID makes a plain ssh:// clone work
AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a
plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x`
was refused as the Linux account name. The account can now save an SSH
user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` —
written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its
own ~/.gitconfig (as the member under S3), so the plain URL works in a
pane, in Clone and in the git bar. Only rules of exactly that shape are
listed or removed; any other insteadOf is the user's and left alone.

Settings → Git → Your git identity now shows the SSH keys one section
per kind of host, each with only its own numbered steps: GitHub · GitLab
· Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in
IAM → paste the key ID and pick the region, then a "Ready" clone line),
plus a collapsed "Other SSH hosts (advanced)". The shared key-type
picker is gone: each section has its own Generate/Replace/Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:31:30 +07:00
242 changed files with 23270 additions and 3729 deletions
+13 -1
View File
@@ -72,9 +72,21 @@
# DASHBOARD_SESSION_SYNC_MS=30000
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
# to 64 MB), PRAGMA optimize, the opt-in retention below and a bounded
# incremental_vacuum. 0 turns it off. Never a full VACUUM (that is Settings →
# Data → Compact, on a click).
# DASHBOARD_DB_MAINTAIN_MS=3600000
# Automatic retention (OFF unless set): finished sessions that started more
# than N days ago (1–3650) are purged by the hourly upkeep. Usually set from
# Settings → Data → Session cleanup, which writes it to the settings file.
# AGENTHUB_RETENTION_DAYS=90
# Update check interval (ms, default 3600000 = 1 h, minimum 60000). A tick
# with no browser connected is skipped; the next browser to connect gets one.
# DASHBOARD_UPDATE_CHECK=0 turns the scheduled check off.
# DASHBOARD_UPDATE_CHECK_INTERVAL_MS=3600000
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
+1
View File
@@ -11,3 +11,4 @@ client/package-lock.json
*.md
*.yml
*.yaml
server/vendor/
+27 -15
View File
@@ -310,7 +310,7 @@ graph TD
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `server/index.js` | Express app setup, middleware, route mounting, static file serving in production, HTTP server creation. Static middleware sets explicit `Cache-Control` headers — `immutable` for `/assets/*`, `no-cache, must-revalidate` for `index.html` / `sw.js` / `manifest.json`, a short revalidation window otherwise — so a rebuild always replaces the in-browser bundle without a hard refresh. Runs a periodic maintenance sweep — cadence derived from `DASHBOARD_STALE_MINUTES` (¼ of the threshold, clamped to 60 s – 5 min, default ~45 min) — that abandons stale sessions with transcript cache eviction and scans active sessions for new compaction entries by reading `sessions.transcript_path` directly (an O(active sessions) lookup; the previous `SELECT DISTINCT json_extract(events.data,'$.transcript_path')` scan grew with the events table and is gone). **Error detection watchdog** runs every 15 seconds: finds active sessions with no recent hook events (>10 s stale), re-reads their transcript files looking for API errors (401 auth, rate limits, quota exhaustion), derives transcript paths from session `cwd` for imported sessions, and marks sessions/agents as `error` when API errors are found — catches cases where the CLI doesn't fire a hook after API errors. The same watchdog also performs **user-interrupt (Esc) recovery**: `Esc` fires no hook, so a cancelled turn would otherwise leave the main agent stuck `working`. It detects this two ways — (a) the transcript's `[Request interrupted by user]` marker, surfaced as `result.pendingInterrupt` from `TranscriptCache` (computed from transcript ordering alone, immune to server/transcript clock skew), and (b) an idle-working fallback for an Esc pressed *before any output* (which writes no marker): when the main agent has been `working` with `current_tool` null and neither a hook event nor the transcript mtime has advanced for `DASHBOARD_WORKING_IDLE_SECONDS` (default 120) — and moves the session to **Waiting** (agent → `waiting`, `awaiting_input_since` stamped) with an `Interrupted` event. Triggers legacy session import (with active-session detection for recently-modified JSONL files) and compaction backfill on startup, plus a boot **liveness reap** — immediately for rows from a previous run and again ~5 s later for rows the startup sync just imported (see the `routes/hooks.js` row) — so sessions that died while the dashboard was down never render as Waiting. Also starts the **Remote Data Sources** sync poller (`startRemoteSourceSync`) that pulls each enabled remote source on an interval — `DASHBOARD_REMOTE_SYNC_MS` (default 60000; 0 disables) — reusing `server/lib/remote-sync.js`. **Graceful shutdown** (SIGTERM/SIGINT) tears down in order: drop realtime clients first (`closeWebSocket` terminates WS clients so their sockets release), then `httpServer.close()` to stop new connections, then `httpServer.closeAllConnections()` to drop lingering keep-alive sockets so `close()` fires promptly, and only **then** close SQLite — inside the `close()` callback, after the HTTP server has drained. Closing the DB before drain made in-flight requests throw `The database connection is not open`; leaving WS/keep-alive sockets open stalled the shutdown until the 5 s force-exit backstop (the "waiting for graceful termination" hang under `node --watch`). A second signal forces an immediate exit |
| `server/openapi.js` | OpenAPI 3.0.3 document generator for the backend API (metadata, schemas, endpoint paths), merging supplementary fragments from `server/openapi-extra/` in `createOpenApiSpec()`. Feeds the raw spec endpoint (`/api/openapi.json`), Swagger UI (`/api/docs`), **ReDoc** (`/api/redoc`, served via `server/lib/redoc.js` with a self-hosted bundle — never a CDN), and the committed `openapi.yaml` regenerated by `npm run openapi:yaml` |
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (bundled via the `redoc` dependency, never a CDN) so the reference works fully offline / air-gapped |
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (vendored under `server/vendor/redoc/` — the `redoc` npm package is not a dependency — never a CDN) so the reference works fully offline / air-gapped |
| `server/openapi-extra/` | Supplementary OpenAPI path/schema fragments merged into the spec by `createOpenApiSpec()` — covers `cc-config.js`, `push.js`, `run.js`, and `misc.js` route groups |
| `server/db.js` | SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable `stmts` object. Tries `better-sqlite3` first, falls back to `node:sqlite` via `compat-sqlite.js`. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like `strftime()` in column defaults added via ALTER TABLE |
| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) Maps every better-sqlite3 API the server uses — `function` (the `trim_event_data` SQL function `db.js` registers at boot), `stmt.pluck()`, the `readonly`/`fileMustExist` open options — and `db.js` exports the chosen class as `Database` so other SQLite files (agy's conversations) open with the same driver. A plugin install has NO better-sqlite3 build, so this is production there; `server/__tests__/compat-sqlite.test.js` boots `db.js` with better-sqlite3 blocked because the rest of the suite never runs on it. |
@@ -1066,7 +1066,7 @@ flowchart TD
- Uses Node.js `http` module directly — no dependencies.
- Resolution order is **env override → discovery file → default**:
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) — while `peersSharingDataDir()` finds another live dashboard on its data dir, which it checks every 30 s — it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
- If neither yields anything, the handler falls back to `4820`.
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
@@ -1223,7 +1223,12 @@ common "nothing changed" tick is just a handful of `stat` calls), funnels them
through the same `parseSessionFile` + `importSession` pipeline as every other
path, and broadcasts `session_created` for newly imported sessions /
`session_updated` for grown ones — the same events hooks emit, so the UI
refreshes live. All timers and watchers are `unref`'d and best-effort; nothing
refreshes live. Two kinds of transcript are not re-parsed: one whose session
hooks are feeding **live** (status `active`, newest event a hook's, a hook
within `DASHBOARD_SYNC_LIVE_HOOK_MS`, default 5 min) is left until it goes
quiet or ends — then caught up once — and one whose session a hook created
(no `imported` flag, which `importSession` skips anyway) only has its durable
snapshot refreshed, never a parse. All timers and watchers are `unref`'d and best-effort; nothing
here blocks shutdown or can take down the server.
### Upload request sequence
@@ -1579,7 +1584,11 @@ re-point if that changes.
`DASHBOARD_CODEX_POLL_MS` beat, a first pass over every journal in the
background at startup). Each file's read offset and parse state live in the
`codex_rollouts` table, so an unchanged file is never re-read and a grown one is
read from where it stopped. `token_count` events are summed per `turn_context`
read from where it stopped (a pass loads every row's size/mtime in one query).
The first pass and one every five minutes walk every journal; the poll's other
passes stat only the two newest day folders plus the journals the table saw
written in the last hour (or not yet read to the end), so a thread resumed
after an hour's quiet in an older folder is picked up by the next full pass. `token_count` events are summed per `turn_context`
model — `input_tokens` includes `cached_input_tokens` (stored as input − cached
plus cache_read) and `output_tokens` includes reasoning. A **fork** copies its
parent's whole history into its own file, token events included, so every total
@@ -1654,7 +1663,9 @@ the binary's own proto descriptors.
**Sessions and tokens** (`server/lib/antigravity-usage.js`,
`startAntigravityUsagePoll`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default
15000, `0` disables). agy writes one SQLite file per conversation,
15000, `0` disables; after a pass that read nothing, wrote nothing and left no
fresh session to settle it slows to once a minute until one finds work —
`server/lib/backoff-poll.js`). agy writes one SQLite file per conversation,
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`. Each is opened
read-only whenever its mtime (or its non-empty `-wal`'s) changes;
`antigravity_conversations` holds the mtime read, so a session the user deleted
@@ -1743,7 +1754,8 @@ is ever inferred.
## Cursor Sessions
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables; idle passes slow it to
once a minute like the Antigravity poll) reads the agent
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
@@ -2225,7 +2237,7 @@ sequenceDiagram
| Component | Responsibility |
| --- | --- |
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 3 600 000 = 1 h, floor 60 000). A tick with no `/ws` browser connected (`getConnectionCount`) is skipped, and the first browser to connect afterwards (`onConnection` in `server/websocket.js`) gets one check 3 s later if the last one is older than the interval; the manual `POST /api/updates/check` is unaffected. First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
@@ -2325,7 +2337,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`, which appends the new server's stdout/stderr to `data/agenthub-server.log` like `agenthub start` (it used to discard them, so a crash after an update left no stack trace). A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
---
@@ -2356,7 +2368,7 @@ graph TD
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
BUBBLE["SpeechBubble.tsx<br/>bubble"]
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
PANEL["PetChat.tsx + SagiInfo.tsx<br/>the chat window: status strip, Needs you,<br/>Mute / Clear alerts, local answers, AI chat"]
CSS["sagi.css<br/>keyframes + per-mood expressions"]
end
@@ -2386,8 +2398,8 @@ flowchart LR
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
DERIVED --> AVATAR["SagiAvatar"]
DERIVED --> BUBBLE["SpeechBubble"]
DERIVED --> PANEL["SagiPanel"]
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
DERIVED --> PANEL["PetChat (+ SagiInfo)"]
PANEL -->|"unmatched question"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]
@@ -2407,10 +2419,10 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
| **`SagiInfo.tsx`** | Pure presentational pieces of the chat window: `PetNameButton` (click to rename — `agenthub.pet.names`, synced to the account) and `SagiInfo`, the status strip under the header — live / waiting / errored and the connection always shown; expanded (by default while something needs you) a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts. |
| **`PetChat.tsx`** | The pet's whole UI — clicking the pet opens it (there is no separate panel): header (name, pat heart, Open in workspace, New chat, close), the `SagiInfo` strip, then the conversation. Before a chat runs, a question `intents.ts` can answer is answered in place (`onLocal`); anything else goes to the AI. Styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard` — numbered menus and Claude's unnumbered folder-trust prompt alike) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and reopening the window continues it; **New chat** stops that session (`DELETE /api/run/:id?archive=1`, asking first only mid-reply); an ended pane is never resumed — the next message starts a new session. |
| **`Sagi.tsx`** | Shell component. A pat (the chat's heart, a typed pat, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns the chat's open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent go to `PetChat`'s AI session; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
+11 -11
View File
File diff suppressed because one or more lines are too long
+407 -82
View File
@@ -1,6 +1,6 @@
# Installation
A step-by-step guide to get AgentHub — the local control surface for Claude Code agents — up and running on your machine, with optional sections for importing history and running in a container.
The one install-and-setup guide for AgentHub — the local control surface for Claude Code agents: the plugin install, a checkout install, configuration, the database, importing history, and troubleshooting. (`SETUP.md` is only a pointer here now.)
## Fastest path — install it as a Claude Code plugin
@@ -17,17 +17,20 @@ on PATH — no clone, no `npm run setup`, no
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
the server stores data through `node:sqlite`). Full behavior, including the
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
the server stores data through `node:sqlite`), plus `git` and `tmux` from
[Step 0](#step-0--system-packages). Full behavior, including the uninstall
cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md). If
the dashboard never comes up, see
[Plugin bootstrap never comes up](#plugin-bootstrap-never-comes-up).
Follow the steps below instead when you want a checkout — to develop the
dashboard, run it from source, or stay on Node 20.
dashboard or run it from source.
## Requirements
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ (22+ recommended) | Required for server and client |
| Node.js | **22.5+** | Required for server and client (`node:sqlite`). Node 20 is end-of-life and unsupported |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
@@ -76,7 +79,7 @@ Check it:
```bash
tmux -V # e.g. tmux 3.4
git --version
node -v # v20.x / v22.x / v24.x
node -v # v22.5+ / v24.x
```
Once the server is up you can ask it directly — this is the same probe the
@@ -113,8 +116,8 @@ brew install node git tmux # Node >= 22.5 for the plugin install (nvm work
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
```
- **Node:** a plugin install needs **>= 22.5** (it stores data through
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
- **Node:** **>= 22.5** (the plugin stores data through `node:sqlite`; a
checkout needs the same). The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
@@ -172,24 +175,20 @@ cd AgentHub
## Step 2 — Install dependencies
```bash
npm run setup
npm run setup # or: make setup
```
This installs all server and client dependencies, plus the VS Code extension, and links the `agenthub` CLI.
This runs `npm install` (whose `postinstall` hook installs the client's
dependencies in `client/`), installs `client/` once more explicitly, and links
the `agenthub` CLI (`npm link`; if that has no permission it prints how to link
it by hand). A plain `npm install` is enough to build and run the dashboard —
only the CLI link is missing.
A plain root install already covers server **and** client — a `postinstall` hook installs the client dependencies automatically, so this alone is enough to build and run the dashboard:
With `--ignore-scripts` the `postinstall` hook is skipped; install the client
yourself with `npm --prefix client install`.
```bash
npm install
```
`npm run setup` additionally installs the VS Code extension and links the `agenthub` CLI. (If you install with `--ignore-scripts`, the `postinstall` hook is skipped — run `cd client && npm install` manually in that case.)
Or via Makefile:
```bash
make setup
```
`better-sqlite3` gyp/compile warnings here are harmless — see
[Troubleshooting](#better-sqlite3-errors-during-npm-install--npm-run-setup).
---
@@ -234,7 +233,7 @@ After starting a Claude Code session, you should see:
- **Dashboard** — stats updating in real-time
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
If nothing appears after 30 seconds, see [Troubleshooting](#no-sessions-appearing-after-starting-claude-code).
### PWA install (optional)
@@ -246,57 +245,110 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
---
## Step 5 — (Optional) Import existing Claude Code history
## Step 5 — (Optional) Importing existing Claude Code history
The server **automatically imports** every session under
`~/.claude/projects/` on startup, so if you've used Claude Code on this
machine before, your historical sessions, agents, events, token counts,
and cost totals should already be visible in the Sessions list.
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
To bring in history from another machine, a backup, or a `.tar.gz`
archive a teammate sent you, use **Settings → Import History** in the
UI. It supports three modes:
### Pick the right mode
```mermaid
flowchart LR
UI["Settings →<br/>Import History"] --> M1["Rescan default folder<br/>~/.claude/projects"]
UI --> M2["Scan a folder<br/>any absolute path"]
UI --> M3["Upload files<br/>.jsonl / .zip / .tar.gz / .gz"]
M1 --> P["Same parser as live<br/>hook ingestion"]
M2 --> P
M3 --> P
P --> DB[("SQLite<br/>sessions + tokens + cost")]
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
style UI fill:#a855f7,stroke:#c084fc,color:#fff
style P fill:#f59e0b,stroke:#fbbf24,color:#000
style DB fill:#10b981,stroke:#34d399,color:#fff
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
Re-imports are idempotent: sessions are deduplicated by UUID and
compaction baselines preserve pre-compaction token totals, so running
the importer twice never double-counts tokens or cost.
### Step-by-step: moving history from one machine to another
Verify it worked by opening the **Analytics** page and checking that
per-model token totals and estimated cost look correct. Full walkthrough
with per-OS archive commands in
[SETUP.md → Importing existing Claude Code history](./SETUP.md#importing-existing-claude-code-history).
### Optional: tune import limits
**On the source machine**, bundle the projects folder:
If you regularly import very large archives, these environment variables
can be raised (the defaults are generous for typical use):
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
| Variable | Default | Purpose |
| --------------------------------- | ------- | ----------------------------------------------------------- |
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Set them before `npm run dev` or `npm start`:
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks. If you regularly import very large archives, raise them
before `npm run dev` or `npm start` (also listed under
[Environment variables](#environment-variables)):
| Variable | Default | Purpose |
| ----------------------------------- | ------- | ---------------------------------------------------------- |
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
```bash
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES=17179869184 npm start # allow 16 GB extraction
```
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Production mode
@@ -328,44 +380,260 @@ and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGIN
---
## Optional: Agent extension packs
## How it works
This repository includes extension packs for both Claude Code and Codex.
AgentHub integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
- Claude Code loads project extensions from:
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/`
- `skills/` (shipped as the `agenthub` plugin's skills)
- Codex project packs live under `.codex/`:
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- Codex:
- `AGENTS.md`
- `.codex/rules/`
- `.codex/agents/`
- `.codex/skills/`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically on first run (`data/dashboard.db` in a checkout, unless `DASHBOARD_DB_PATH` says otherwise). The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `npm run setup` shows `better-sqlite3` errors
### Plugin bootstrap never comes up
This is expected and **non-fatal**. `better-sqlite3` is a native C++ module listed as an optional dependency. If prebuilt binaries are not available for your Node version or platform, npm will print gyp/compilation errors but still complete successfully.
The plugin's `SessionStart` hook only checks a fast path and hands the real
work (npm install, UI build, server start) to a detached background worker, so
a failure there does not show in the session. When the worker's last run
failed, the next session start prints a one-time **AgentHub:** message naming
the log to read. Everything is logged under `~/.claude/agenthub/runtime/`:
At runtime the server uses this fallback chain:
| File | What it holds |
|---|---|
| `bootstrap.log` | Each bootstrap step and why it stopped (Node too old, install failed, superseded build, …) |
| `npm-install.log` | npm's output for the runtime dependency install |
| `client-build.log` | The dashboard UI build (the API still works when only this fails) |
| `server.log` | The server's own output, when it was started detached |
| `last-failure.json` | The last failure the session-start message reports (removed once a bootstrap succeeds) |
1. **`better-sqlite3`** — used when prebuilt binaries are available (Node 20/22/24 on Windows x64, macOS arm64/x64, Linux x64/arm64)
2. **`node:sqlite`** — Node.js built-in SQLite module, used automatically on Node 22+ when `better-sqlite3` is unavailable
Then run `/agenthub-doctor` from Claude Code — it checks Node, the runtime
dependencies, the server and the service unit, and names the fix for each
failing row. `/agenthub-update` re-runs the bootstrap in the foreground with its
output visible. The most common causes: Node older than 22.5 (`node -v` in the
shell Claude Code was started from), no network or a proxy blocking the npm
registry (`npm-install.log`), and a port held by another program (`server.log`,
see [Port 4820 already in use](#port-4820-already-in-use)).
If you see an error box at startup saying *"SQLite backend not available"*, either:
### `better-sqlite3` errors during `npm install` / `npm run setup`
- **Upgrade to Node.js 22+** (recommended — zero native dependencies needed), or
- **Install build tools** so `better-sqlite3` can compile from source:
- **Windows:** `npm install -g windows-build-tools` or install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
Only a **checkout** install sees these — the plugin installs with
`--ignore-scripts` and never builds `better-sqlite3`. They are **harmless**:
`better-sqlite3` is an optional dependency, and when it cannot be built npm
skips it and the server uses Node's built-in `node:sqlite` (Node 22.5+). You do
**not** need Python, Visual Studio Build Tools or a C++ compiler.
Then run: `npm rebuild better-sqlite3`
To build it anyway (it is used when present), install a toolchain and run
`npm rebuild better-sqlite3`:
- **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
### "SQLite backend not available" error on startup
Neither `better-sqlite3` nor `node:sqlite` could be loaded — almost always a
Node older than 22.5. Upgrade Node.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
### `npm run dev` fails immediately
@@ -375,7 +643,7 @@ Ensure both server and client dependencies are installed:
npm run setup
```
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both root and `client/`, then re-run setup:
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both the root and `client/`, then re-run setup:
```bash
rm -rf node_modules client/node_modules
@@ -409,9 +677,66 @@ that case.
### No sessions appearing after starting Claude Code
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
**Check 1 — Is the server running?**
---
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
With the plugin, its hooks come with it — run `/agenthub-doctor` instead. For a
checkout, open `~/.claude/settings.json` and confirm it contains a `hooks`
section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
### Port 4820 already in use
When another program holds the port, the server stops with
`Port 4820 is already in use by another program…` (a running AgentHub there is
detected and simply reused). Pick another port:
```bash
DASHBOARD_PORT=4821 npm start # or: DASHBOARD_PORT=4821 npm run dev
```
The Vite dev proxy and the Claude Code hooks follow the port on their own
(`client/vite.config.ts` reads `DASHBOARD_PORT`; the server publishes the port it bound).
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
## Ports
+12 -420
View File
@@ -1,422 +1,14 @@
# Setup Guide
A comprehensive guide to setting up and configuring the Agent Dashboard, including how it integrates with Claude Code, environment variables, container deployment, and troubleshooting common issues.
## How it works
Agent Dashboard integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- `agents/*` (the `agenthub` plugin's own agents)
- Codex:
- `AGENTS.md`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically at `data/dashboard.db` on first run. The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Importing existing Claude Code history
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
### Pick the right mode
```mermaid
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
### Step-by-step: moving history from one machine to another
**On the source machine**, bundle the projects folder:
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks; see the env vars table above for
`AGENTHUB_IMPORT_MAX_BYTES`, `AGENTHUB_IMPORT_MAX_FILES`, and
`AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`.
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `better-sqlite3` errors during `npm install` / `npm run setup`
These warnings are **harmless**. `better-sqlite3` is an optional dependency — if it cannot compile, npm skips it and the server falls back to Node.js built-in `node:sqlite` (available on Node 22+).
You do **not** need Python, Visual Studio Build Tools, or any C++ compiler to run this project on Node 22+.
If you are on Node 20 or 21 and `better-sqlite3` prebuilds are not available for your platform (there is no `node:sqlite` fallback below Node 22), you have two options:
1. **Upgrade to Node.js 22+** — the built-in `node:sqlite` fallback requires no native compilation at all
2. **Install build tools** and run `npm rebuild better-sqlite3`:
- **Windows:** install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu)
### "SQLite backend not available" error on startup
This means neither `better-sqlite3` nor `node:sqlite` could be loaded. The most common cause is running Node.js < 22 without `better-sqlite3` prebuilds. Upgrade to Node.js 22+ to resolve this.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
---
### The run console won't start a run / the terminal never attaches
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
```bash
tmux -V # missing → sudo apt install tmux
curl -s http://localhost:4820/api/run/tmux # {"available":true}
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the **server's**
`PATH` differs from yours — typically a systemd `--user` service, which never
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
re-run `agenthub service uninstall && agenthub service install`. The same
applies to `claude` / `codex`: a run launches them by name.
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
---
### No sessions appearing after starting Claude Code
**Check 1 — Is the server running?**
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
Open `~/.claude/settings.json` and confirm it contains a `hooks` section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
---
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
---
### Events Today shows 0 despite recent activity
This was a known timezone bug (fixed in current version). If you are still seeing this, ensure you are running the latest code and restart the server.
---
### Port 4820 already in use
```bash
DASHBOARD_PORT=4821 npm run dev
```
Then update the Vite proxy in `client/vite.config.ts`:
```ts
proxy: {
"/api": "http://localhost:4821",
"/ws": { target: "ws://localhost:4821", ws: true }
}
```
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
This guide has moved into **[INSTALL.md](./INSTALL.md)** — the one install and
setup guide (it and this file had grown into near-duplicates). Everything that
was here is there:
| Was in SETUP.md | Now in INSTALL.md |
|---|---|
| How it works | [How it works](./INSTALL.md#how-it-works) |
| Hook auto-installation, environment variables, agent extensions, PWA | [Configuration](./INSTALL.md#configuration) |
| Database (clear, Settings page, seed) | [Database](./INSTALL.md#database) |
| Importing existing Claude Code history | [Step 5](./INSTALL.md#step-5--optional-importing-existing-claude-code-history) |
| Scripts reference, Makefile targets | [Scripts reference](./INSTALL.md#scripts-reference) |
| Troubleshooting (incl. "Port 4820 already in use") | [Troubleshooting](./INSTALL.md#troubleshooting) |
+145 -12
View File
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
],
[
"accounts",
"list|add|remove [name] [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts",
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
service: ["install", "uninstall", "status"],
server: ["setup", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
accounts: ["list", "add", "remove"],
accounts: ["list", "add", "remove", "pin", "unpin"],
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
@@ -3934,21 +3934,73 @@ async function cmdServer(sub, flags) {
} else {
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
}
const wasServer = auth.isServerMode();
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
envNote("AGENTHUB_MODE");
console.log(`${c.green("✔")} server mode on (${file})`);
// One Linux user per account (S3) is the default on Linux: install it now
// (one sudo), unless it is already in place or `--no-system` opts out.
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
const shared = flags["no-system"] === true || process.platform !== "linux";
if (!shared) {
if (linux.serverConf()) {
console.log(c.dim(" one Linux user per account is already installed"));
// The system unit serves; it reads the mode only when it starts.
if (!wasServer) {
const r = spawnSync("sudo", [
"-n",
"systemctl",
"restart",
"--no-block",
"agenthub.service",
]);
console.log(
r.status === 0
? `${c.green("✔")} agenthub.service restarted`
: c.yellow("! restart it: sudo systemctl restart agenthub")
);
}
} else {
console.log(" Installing one Linux user per account (asks for your sudo password)…");
try {
await installSystem({}, settingsEnv);
} catch (e) {
throw new Error(
`${e.message}\n Server mode is on, but every pane still runs as this account.` +
"\n Fix it and run `agenthub server install-system`, or keep one shared" +
" account with `agenthub server setup --no-system`."
);
}
}
// The admin made above (and anyone made before) gets their Linux account.
process.env.AGENTHUB_MODE = "server";
await cmdUsers("provision", [], { all: true });
}
console.log(" Next:");
console.log(
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
shared
? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
: " 1. Log out and back in once, so your shell joins the agenthub groups"
);
console.log(
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
);
console.log(
c.dim(" Members see only their own workspaces, but every pane still runs as this")
);
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
if (shared) {
console.log(
c.yellow(
process.platform === "linux"
? "! --no-system: every pane runs as this one Linux account"
: "! one Linux user per account needs Linux: every pane runs as this one account"
)
);
console.log(
c.dim(" Members see only their own workspaces, but an agent in a pane can read")
);
console.log(
c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).")
);
}
console.log(
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
);
@@ -3989,7 +4041,7 @@ async function cmdServer(sub, flags) {
}
console.error(
c.red(
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
)
);
process.exitCode = 1;
@@ -4234,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
[
"codex account",
"kind",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4249,10 +4312,64 @@ async function cmdCodexAccounts(sub, name, flags) {
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–",
a.full ? "full" : "ok",
pinnedUsers("codex", a.name),
])
);
}
/**
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
* that user's new sessions run on that account (server/lib/account-pins.js).
*/
async function cmdAccountPin(sub, positional, tool) {
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
const [first, second] = positional;
const username = sub === "pin" ? second : first;
if (!username || (sub === "pin" && !first)) {
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
}
const user = require(path.join(REPO_ROOT, "server", "db.js"))
.db.prepare("SELECT id FROM users WHERE username = ?")
.get(username);
if (!user) throw new Error(`no user ${username}`);
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
console.log(c.dim(" Pins apply in server mode only."));
}
if (!pin.account) {
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
return;
}
const switched = await pins.applyPin(user.id, tool);
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
}
/** What `applyPin` did, for the CLI. */
const SWITCH_NOTE = {
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
kept: "Their home already holds it.",
"own-login":
"They signed in with their own login, which is left alone: no pool login is written over it.",
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
"next-session": "New sessions use it; running ones keep their account until they end.",
};
/** The `pinned` column: who an admin pinned to `account`. */
function pinnedUsers(tool, account) {
try {
return (
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
.listPins(tool)
.filter((p) => p.account === account)
.map((p) => p.username)
.join(",") || "–"
);
} catch {
return "–";
}
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional, flags = {}) {
serverAuth(); // the same settings + database the server reads
@@ -4264,6 +4381,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex");
}
if (sub === "pin" || sub === "unpin")
return cmdAccountPin(sub, positional, flags.tool || "claude");
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") {
console.log(
@@ -4285,7 +4404,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table(
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
[
"account",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota 5h",
"quota wk",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4304,6 +4434,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
: a.quota?.error
? `no quota (${a.quota.error})`
: "ok",
pinnedUsers("claude", a.name),
])
);
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
@@ -4311,7 +4442,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
return;
}
console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
c.red(
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
)
);
process.exitCode = 1;
}
+7 -3
View File
@@ -89,7 +89,9 @@ const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
const Administration = lazy(() =>
import("./pages/Administration").then((m) => ({ default: m.Administration }))
);
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
function PageFallback() {
@@ -237,13 +239,15 @@ export default function App() {
}
/>
<Route
path="users"
path="admin"
element={
<Page>
<Users />
<Administration />
</Page>
}
/>
{/* The Users page became Administration's first tab. */}
<Route path="users" element={<Navigate to="/admin" replace />} />
<Route
path="settings/*"
element={
+4
View File
@@ -165,12 +165,15 @@ export function MenuItem({
danger,
disabled,
testId,
title,
children,
}: {
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
testId?: string;
/** Hover text — e.g. why a disabled item cannot run. */
title?: string;
children: ReactNode;
}) {
return (
@@ -179,6 +182,7 @@ export function MenuItem({
role="menuitem"
data-testid={testId}
disabled={disabled}
title={title}
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
onClick={onSelect}
>
+14 -3
View File
@@ -76,7 +76,7 @@
*
* ----------------------------------------------------------------------------- */
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
import { useOnboarding } from "../lib/userPrefs";
import { OnboardingTour } from "./onboarding/OnboardingTour";
// Loaded only for an account that has not finished onboarding: the wizard
// carries the appearance, pet and notification settings screens.
const OnboardingWizard = lazy(() =>
import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard }))
);
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const onboarding = useOnboarding();
const [updateAvailable, setUpdateAvailable] = useState(false);
const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
<UpdateNotifier />
<InterruptedSessionsPrompt />
<SagiMount />
<OnboardingWizard />
{onboarding.loaded && !onboarding.onboardedAt && (
<Suspense fallback={null}>
<OnboardingWizard />
</Suspense>
)}
<OnboardingTour />
{drawer && drawerOpen && (
<div
+83 -33
View File
@@ -1,7 +1,11 @@
/**
* @file PetChat.tsx
* @description The pet's chat window. A question its Ask box cannot answer
* locally opens here instead of leaving the page: the first message starts a
* @description The pet's chat window — its whole UI: clicking the pet opens
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
* who are you…) is answered right here; anything else — and every message once
* a chat runs — goes to the AI: the first message starts a
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
@@ -11,16 +15,17 @@
* attached pane, exactly like the workspace's own Chat view), so nothing new
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
* forgets it (the session stays in the workspace), **Open in workspace**
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
* reopening the window continues it; **New chat** STOPS that session (kills
* its pane and archives it, asking first only mid-reply), **Open in workspace**
* opens its pane there. Opening never resumes an ended session: once the pane
* is gone, the next message starts a new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { api } from "../../lib/api";
import type { TranscriptMessage } from "../../lib/types";
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
@@ -33,6 +38,8 @@ import {
type PaneMenu,
} from "../../lib/panePaste";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { TerminalView } from "../run/TerminalView";
import { PaneMenuCard } from "../run/PaneChat";
@@ -141,14 +148,21 @@ function toolSummary(names: string[]): string {
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
}
/** What the container made of a message: an answer the pet gives itself, or
* `handled` (it took the message elsewhere); null sends it to the AI. */
export type PetLocalReply = { answer: string } | { handled: true } | null;
interface PetChatProps {
/** The pet's shown name, for the header and greeting. */
name: string;
/** A question to send as soon as the chat opens (the Ask box's handoff). */
initialPrompt?: string | null;
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
onPromptTaken?: () => void;
onBack: () => void;
/** Shown in place of the plain name (the rename button). */
title?: ReactNode;
/** Extra header buttons, before Open in workspace (the pat heart). */
headerActions?: ReactNode;
/** Under the header: what the pet knows (status, sessions that need you). */
info?: ReactNode;
/** Asked first while no chat runs. */
onLocal?: (text: string) => PetLocalReply;
onClose: () => void;
/** Opens the chat's pane in its workspace. */
onOpenWorkspace: (laneId: number, runId: string) => void;
@@ -158,9 +172,10 @@ interface PetChatProps {
export function PetChat({
name,
initialPrompt,
onPromptTaken,
onBack,
title,
headerActions,
info,
onLocal,
onClose,
onOpenWorkspace,
onBusy,
@@ -175,6 +190,8 @@ export function PetChat({
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [menu, setMenu] = useState<PaneMenu | null>(null);
// The pet's own answers, given before any chat ran.
const [local, setLocal] = useState<Line[]>([]);
const listRef = useRef<HTMLDivElement>(null);
const remember = (ref: PetChatRef | null) => {
@@ -248,7 +265,7 @@ export function PetChat({
return () => window.clearInterval(timer);
}, [chat]);
const lines = chatLines(messages);
const lines = [...local, ...chatLines(messages)];
// The pending line shows until the transcript has it.
useEffect(() => {
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
@@ -269,6 +286,19 @@ export function PetChat({
const prompt = text.trim();
if (!prompt || starting) return;
setError(null);
if (!chat && onLocal) {
const reply = onLocal(prompt);
if (reply && "handled" in reply) return;
if (reply) {
const n = Date.now();
setLocal((prev) => [
...prev,
{ key: `lu${n}`, role: "user", text: prompt },
{ key: `la${n}`, role: "assistant", text: reply.answer },
]);
return;
}
}
setPending(prompt);
if (chat) {
// Typed into the attached pane; it may still be attaching.
@@ -288,7 +318,7 @@ export function PetChat({
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
@@ -302,17 +332,35 @@ export function PetChat({
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t]
[chat, starting, t, onLocal]
);
// The Ask box's question, sent once.
const taken = useRef(false);
useEffect(() => {
if (!initialPrompt || taken.current) return;
taken.current = true;
onPromptTaken?.();
void send(initialPrompt);
}, [initialPrompt, onPromptTaken, send]);
// New chat: the old one's session is stopped, not left running unseen —
// asking first only while it is still writing a reply.
const newChat = async () => {
if (chat) {
if (
busy &&
!(await confirmDialog({
title: t("sagi.chat.endTitle"),
message: t("sagi.chat.endBody"),
confirmLabel: t("sagi.chat.endConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(chat.runId, { archive: true });
} catch (err) {
toast.error(err);
return;
}
}
remember(null);
setLocal([]);
setPending(null);
setError(null);
};
const submit = (e?: FormEvent) => {
e?.preventDefault();
@@ -333,11 +381,11 @@ export function PetChat({
>
{/* header */}
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
<ArrowLeft size={16} />
</IconButton>
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
@@ -352,6 +400,7 @@ export function PetChat({
{busy ? t("sagi.chat.typing") : toolLabel}
</div>
</div>
{headerActions}
{chat && (
<IconButton
label={t("sagi.chat.openWorkspace")}
@@ -362,8 +411,8 @@ export function PetChat({
)}
<IconButton
label={t("sagi.chat.new")}
onClick={() => remember(null)}
disabled={!chat || starting}
onClick={() => void newChat()}
disabled={(!chat && !local.length) || starting}
>
<SquarePen size={15} />
</IconButton>
@@ -372,6 +421,8 @@ export function PetChat({
</IconButton>
</div>
{info}
{/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && (
@@ -475,10 +526,9 @@ export function PetChat({
e.preventDefault();
submit();
} else if (e.key === "Escape") {
// Esc leaves the chat for the panel, not the whole mascot.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onBack();
onClose();
}
}}
/>
+63 -82
View File
@@ -5,17 +5,20 @@
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
* tick, WS reducer) runs only while it is on. Persists across routes and
* shares the single WebSocket.
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI.
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
* bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path: an unmatched Ask query starts a claude session in
* the "Sagi" workspace carrying it as the first prompt, then opens that
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* The "do the job" path: an unmatched question is the chat's — it starts a
* session of the default AI tool in the "Sagi" workspace; a default tool the
* chat cannot read back starts there and its session view opens
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -39,7 +42,7 @@
* ## Internal dependencies
* - `./SagiAvatar`
* - `./SpeechBubble`
* - `./SagiPanel`
* - `./SagiInfo`
* - `./useSagiBrain`
* - `./useSagiPosition`
* - `./intents`
@@ -78,12 +81,13 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble";
import { SagiPanel } from "./SagiPanel";
import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount";
@@ -94,7 +98,8 @@ import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
import { PetChat, petChatTool } from "./PetChat";
import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
// Stroking: this many left/right turns of the pointer over the pet, within
// STROKE_MS and with no button held, is a pat; then a pause before the next.
@@ -126,23 +131,6 @@ interface Anchor {
dragging: boolean;
}
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
/**
* Fixed-position wrapper that places its content next to the avatar and clamps
* it inside the viewport. It measures itself (and re-measures on content/size
@@ -241,10 +229,8 @@ function SagiBody() {
// The name it goes by: the user's nickname, the pet's own, else Sagi.
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
const [size, setSize] = useState(() => sagiPrefs.getSize());
// The chat window (PetChat.tsx): the pet's whole UI.
const [open, setOpen] = useState(false);
// The chat window (PetChat.tsx), and the Ask question it should send.
const [chatOpen, setChatOpen] = useState(false);
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
@@ -296,7 +282,6 @@ function SagiBody() {
setOpen((v) => !v);
} else if (e.key === "Escape") {
setOpen(false);
setChatOpen(false);
}
};
window.addEventListener("keydown", onKey);
@@ -346,7 +331,6 @@ function SagiBody() {
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
brain.dismissBubble();
setOpen(false);
setChatOpen(false);
};
api.run
.list()
@@ -356,21 +340,17 @@ function SagiBody() {
[brain, navigate]
);
const onAsk = useCallback(
(query: string): string | null => {
// Asked by the chat before any session runs.
const onLocal = useCallback(
(query: string): PetLocalReply => {
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
if (result.kind === "answer") {
if (result.moment) brain.playMoment(result.moment);
return result.text;
}
// Handoff: a question the pet cannot answer goes to the chat window,
// which starts a session of the default AI tool and shows it as chat.
if (petChatTool()) {
setChatPrompt(result.prompt);
setOpen(false);
setChatOpen(true);
return null;
return { answer: result.text };
}
// A question the pet cannot answer: the chat starts a session of the
// default AI tool and shows it as chat.
if (petChatTool()) return null;
// A default tool the chat cannot read back (not Claude/Codex): start it
// in the "Sagi" workspace and open its pane there, as before.
const prompt = result.prompt;
@@ -392,7 +372,7 @@ function SagiBody() {
brain.setThinking(false);
}
})();
return null;
return { handled: true };
},
[brain, navigate, t, name]
);
@@ -431,46 +411,49 @@ function SagiBody() {
return (
<>
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
does not - it is Sagi talking, and it has to stay by its mouth. */}
{!place.dragging && open && (
<SagiFlyout anchor={anchor}>
<SagiPanel
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
onPat={brain.pat}
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
onAsk={onAsk}
onClose={() => setOpen(false)}
/>
</SagiFlyout>
)}
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1), and kept mounted: unmounting would detach its terminal and
lose a half-typed message. */}
{chatOpen && (
it 1:1). Closing unmounts it; its chat is remembered, so reopening
re-attaches the same session. */}
{open && (
<SagiFlyout anchor={anchor} beside>
<PetChat
name={name}
initialPrompt={chatPrompt}
onPromptTaken={() => setChatPrompt(null)}
onBack={() => {
setChatOpen(false);
setOpen(true);
}}
onClose={() => setChatOpen(false)}
title={
<PetNameButton
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
/>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setChatOpen(false);
setOpen(false);
}}
onBusy={onBusy}
/>
@@ -491,7 +474,7 @@ function SagiBody() {
</SagiFlyout>
)}
{!open && !menu && !chatOpen && brain.bubble && (
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble
bubble={brain.bubble}
@@ -519,18 +502,16 @@ function SagiBody() {
// A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar.
if (place.consumeDrag()) return;
if (chatOpen) setChatOpen(false);
else setOpen((v) => !v);
setOpen((v) => !v);
}}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
setChatOpen(false);
setMenu(true);
}}
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open || chatOpen}
aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open}
title={t("sagi.avatarTitle", { name })}
>
{pet.id === "sagi" || !("kind" in pet) ? (
+297
View File
@@ -0,0 +1,297 @@
/**
* @file SagiInfo.tsx
* @description What the pet knows, shown inside its chat window (`PetChat`):
* the name you rename by clicking it (`PetNameButton`), and a status strip
* (`SagiInfo`) — live / waiting / errored counts and the connection state
* always in sight, and on expanding, the sessions that need the user, by
* name, plus Mute and Clear alerts. Pure presentational: all data and
* behavior are injected by the container (`Sagi.tsx`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import {
AlertTriangle,
Bell,
BellOff,
ChevronDown,
ChevronRight,
Hourglass,
Radio,
Trash2,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4;
/** The pet's name; a click renames it in place (empty = its own name). */
export function PetNameButton({
name,
ownName,
nickname,
onRename,
}: {
name: string;
ownName: string;
nickname: string;
onRename: (name: string) => void;
}) {
const { t } = useTranslation("settings");
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const save = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
return renaming === null ? (
<button
type="button"
className="max-w-full truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => setRenaming(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
save();
} else if (e.key === "Escape") {
// Esc cancels the rename only, not the whole chat.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setRenaming(null);
}
}}
/>
);
}
interface SagiInfoProps {
attention: ReadonlyArray<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
onClearAlerts: () => void;
onNavigate: (route: string) => void;
/** Expanded at first: the sessions that need the user and the actions. */
defaultOpen?: boolean;
}
/** The status strip under the chat's header. */
export function SagiInfo({
attention,
onOpenSession,
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
defaultOpen = false,
}: SagiInfoProps) {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(defaultOpen);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div className="pet-chat-info border-b border-border/60 px-3 py-2">
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-lg text-left"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={t("sagi.chat.status")}
title={t("sagi.chat.status")}
>
<StatChip
icon={Radio}
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
<span
className={`ml-auto inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
? "bg-status-success/15 text-status-success"
: "bg-status-danger/15 text-status-danger"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-status-success" : "bg-status-danger"
}`}
aria-hidden
/>
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
<Chevron size={13} className="shrink-0 text-fg-muted" aria-hidden />
</button>
{open && (
<div className="mt-2 max-h-48 overflow-y-auto">
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
</div>
</div>
)}
</div>
);
}
interface Tone {
wrap: string;
value: string;
icon: string;
}
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-fg-secondary",
icon: "text-fg-muted",
};
const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
amber: {
wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-status-warning",
icon: "text-status-warning",
},
red: {
wrap: "border-status-danger/30 bg-status-danger/10",
value: "text-status-danger",
icon: "text-status-danger",
},
muted: TONE_MUTED,
};
/** One count, compact: icon, number, label on one line. */
function StatChip({
icon: Icon,
label,
value,
tone,
}: {
icon: LucideIcon;
label: string;
value: number;
tone: string;
}) {
const t = TONES[tone] ?? TONE_MUTED;
return (
<span className={`inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 ${t.wrap}`}>
<Icon size={11} className={t.icon} aria-hidden />
<span className={`text-xs font-semibold tabular-nums ${t.value}`}>{value}</span>
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
</span>
);
}
function ActionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}
-392
View File
@@ -1,392 +0,0 @@
/**
* @file SagiPanel.tsx
* @description Expanded Sagi panel: a live status strip (live / waiting /
* errored stat chips + connection state), quick navigation actions, and a
* local "Ask" box. Pure presentational - all data and the ask/navigation
* behavior are injected by the container.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `./brain`
*
* ## Public surface
* - `SagiPanel` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **SagiPanel**
* 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.
*
* ----------------------------------------------------------------------------- */
import { useState, type FormEvent, type ReactNode } from "react";
import {
Bell,
BellOff,
Trash2,
X,
Send,
AlertTriangle,
Hourglass,
Radio,
Heart,
ChevronRight,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4;
interface SagiPanelProps {
/** The name the pet goes by now. */
name: string;
/** The pet's own name, which an empty nickname falls back to. */
ownName: string;
/** The user's nickname for the pet, "" when none. */
nickname: string;
onRename: (name: string) => void;
onPat: () => void;
/** Errored, then waiting sessions, named. */
attention: ReadonlyArray<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
onClearAlerts: () => void;
onNavigate: (route: string) => void;
/** Returns an answer to display, or null when the query was handed off. */
onAsk: (query: string) => string | null;
onClose: () => void;
}
export function SagiPanel({
name,
ownName,
nickname,
onRename,
onPat,
attention,
onOpenSession,
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
onAsk,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const saveName = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
const result = onAsk(query);
setAnswer(result); // null means it handed off (container navigates/closes)
setQuery("");
};
return (
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
role="dialog"
aria-label={t("sagi.panel.label", { name })}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base leading-none" aria-hidden>
🐾
</span>
{renaming === null ? (
<button
type="button"
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => setRenaming(e.target.value)}
onBlur={saveName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveName();
} else if (e.key === "Escape") {
// Esc cancels the rename only, not the whole panel.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setRenaming(null);
}
}}
/>
)}
<span
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
? "bg-status-success/15 text-status-success"
: "bg-status-danger/15 text-status-danger"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-status-success" : "bg-status-danger"
}`}
aria-hidden
/>
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
</div>
<div className="flex shrink-0 items-center">
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={onPat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
>
<X size={15} />
</button>
</div>
</div>
<div className="p-3">
{/* status stat chips */}
<div className="mb-3 grid grid-cols-3 gap-1.5">
<StatChip
icon={Radio}
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
</div>
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-3">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
</div>
{/* ask */}
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.panel.askPlaceholder", { name })}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t("sagi.panel.ask", { name })}
/>
<button
type="submit"
className="btn-primary justify-center px-2.5 py-2"
aria-label={t("sagi.panel.send")}
>
<Send size={14} />
</button>
</form>
{answer && (
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
{answer}
</p>
)}
</div>
</div>
);
}
interface Tone {
wrap: string;
value: string;
icon: string;
}
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-fg-secondary",
icon: "text-fg-muted",
};
const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
amber: {
wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-status-warning",
icon: "text-status-warning",
},
red: {
wrap: "border-status-danger/30 bg-status-danger/10",
value: "text-status-danger",
icon: "text-status-danger",
},
muted: TONE_MUTED,
};
function StatChip({
icon: Icon,
label,
value,
tone,
}: {
icon: LucideIcon;
label: string;
value: number;
tone: string;
}): ReactNode {
const t = TONES[tone] ?? TONE_MUTED;
return (
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
<Icon size={13} className={t.icon} aria-hidden />
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
{value}
</span>
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
</div>
);
}
function ActionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}
@@ -1,6 +1,6 @@
/**
* @file Sagi.test.tsx
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
import { api } from "../../../lib/api";
import type { WSMessage, Session } from "../../../lib/types";
function renderSagi() {
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
});
it("opens the panel on click and answers a local status question", () => {
it("opens the chat on click and answers a local status question there", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(panel).toBeInTheDocument();
const input = within(panel).getByLabelText(/ask sagi/i);
const input = within(panel).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "status" } });
fireEvent.submit(input.closest("form")!);
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
expect(within(btn).getByText("1")).toBeInTheDocument();
});
it("names the errored session in its bubble and in the panel", () => {
it("names the errored session in its bubble and in the chat", () => {
renderSagi();
act(() => {
eventBus.publish({
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
});
@@ -121,43 +122,54 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
});
it("renames the pet from the panel header", () => {
it("renames the pet from the chat header", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
});
it("hands a question it cannot answer to the chat window", () => {
it("hands a question it cannot answer to the AI", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const input = screen.getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
});
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
const input = screen.getByLabelText(/ask sagi/i);
const input = within(chat).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
ensure.mockRestore();
});
it("a pat shows its reaction on the pet even while the panel is open", () => {
it("reopens the remembered chat, and New chat stops its session", async () => {
localStorage.setItem(
"agenthub.pet.chat",
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
);
const get = vi
.spyOn(api.run, "get")
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
});
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
get.mockRestore();
kill.mockRestore();
});
it("a pat shows its reaction on the pet even while the chat is open", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
@@ -175,16 +187,16 @@ describe("Sagi widget", () => {
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
});
it("reflects the live count in the panel status", () => {
it("reflects the live count in the chat status", () => {
renderSagi();
act(() => {
eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active"));
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
// The "live" stat chip shows value 2 next to its label.
const liveChip = within(panel).getByText("live").closest("div")!;
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("2")).toBeInTheDocument();
});
@@ -194,7 +206,7 @@ describe("Sagi widget", () => {
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
});
it("a tap (no movement) still opens the panel", () => {
it("a tap (no movement) still opens the chat", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => {
@@ -202,7 +214,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
});
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
@@ -231,7 +243,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
// No position was persisted because no real drag happened.
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
});
+3 -3
View File
@@ -73,7 +73,7 @@ import {
ChevronDown,
FolderTree,
LogOut,
UsersRound,
ShieldCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
@@ -479,11 +479,11 @@ export function Sidebar({
{[
// The file manager serves a shared server; locally it is the desktop's.
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
// Server mode: the Users page, for an admin or analyst.
// Server mode: Administration (users, server), for an admin or analyst.
...(auth.state.mode === "server" &&
auth.state.user &&
auth.state.user.role !== "developer"
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
: []),
].map(({ to, icon: Icon, key }) => {
const label = t(key);
+56 -40
View File
@@ -9,8 +9,12 @@
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
* workspace or session, then at most every 30 s while events or session
* updates arrive (spend only moves with work) and every 2 min otherwise, while
* the tab is visible. The session counts re-read only when a session is
* created or its status / waiting / archived state changes (every 60 s
* otherwise). Each figure carries its
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
/** Spend moves only with work: at most this often while events arrive… */
const COST_POLL_MS = 30_000;
/** …and this often regardless (date rollover, budget period, a missed frame). */
const COST_FALLBACK_MS = 120_000;
/** The counts' safety poll; session frames that change a count drive the rest. */
const COUNTS_FALLBACK_MS = 60_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
@@ -384,51 +394,57 @@ export function StatusBar({
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
// Only a frame that moves a session between the counted states (a new
// session, a status / waiting / archived change) re-reads: a session row is
// re-broadcast on almost every hook.
const [countsChanged] = useState(() => sessionChangeMatcher());
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
// Bumped on every lane/session switch: an answer for the previous one is dropped.
const costGen = useRef(0);
const loadCosts = useCallback(() => {
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live() && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live() && setSessionCost(r))
.catch(() => undefined);
}
}, [lane, sessionId]);
useEffect(() => {
let live = true;
costGen.current++;
// Another workspace or session: never show the previous one's figure.
setLaneCost(null);
setSessionCost(null);
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
if (document.visibilityState === "visible") loadCosts();
return () => {
live = false;
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
costGen.current++;
};
}, [lane, sessionId]);
}, [loadCosts]);
useLiveRefresh(loadCosts, {
intervalMs: COST_FALLBACK_MS,
throttleMs: COST_POLL_MS,
match: (msg) =>
msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created",
});
return (
<footer
+6 -3
View File
@@ -33,13 +33,16 @@ export function Switch({
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
// Hover darkens the track (enabled only), so it reads as clickable.
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
checked
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
}`}
>
<span
aria-hidden
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
checked ? "translate-x-[18px]" : "translate-x-0.5"
}`}
/>
+417
View File
@@ -0,0 +1,417 @@
/**
* @file ServerPanel.tsx
* @description The Administration page's Server tab: the machine AgentHub
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
* machine, resident memory, process count, the busiest processes by name, and
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
* at once. Read-only — the only action here is the members' tmux card
* (`MemberTmuxCard`, an admin's restart).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Cpu,
HardDrive,
MemoryStick,
MonitorCog,
RefreshCw,
Server,
} from "lucide-react";
import {
api,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
type UserStorage,
} from "../../lib/api";
import { formatBytes } from "../../lib/format";
import { useSpin } from "../../hooks/useSpin";
import { StatCard } from "../StatCard";
import { UserAvatar } from "../users/UserAvatar";
import { MemberTmuxCard } from "../users/MemberTmuxCard";
const POLL_MS = 5000;
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** A thin bar, amber past 75 %, red past 90 %. */
function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
}
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("admin");
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const load = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Names, pictures and home sizes: read once, they change slowly.
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
api.users
.storage()
.then((r) => setStorage(r.storage))
.catch(() => setStorage({}));
}, []);
const byId = new Map((users ?? []).map((u) => [u.id, u]));
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const loading = !stats && !error;
const mem = stats?.memory;
const toggle = (key: string) =>
setOpen((s) => {
const n = new Set(s);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("server.hostGroup");
if (g.kind === "system") return t("server.systemGroup");
const u = g.userId != null ? byId.get(g.userId) : undefined;
return u?.displayName || g.username || "";
};
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="space-y-6" data-testid="server-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<Server className="w-4 h-4 text-accent" />
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
</h2>
{stats && (
<p className="mt-0.5 text-xs text-fg-muted">
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
{" · "}
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
</p>
)}
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("server.cpu")}
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
icon={Cpu}
trend={
stats
? t("server.cores", {
n: stats.cpu.cores,
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
})
: undefined
}
raw={stats?.cpu.model ?? undefined}
help={t("server.cpuHelp")}
loading={loading}
/>
<StatCard
label={t("server.memory")}
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
icon={MemoryStick}
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.swap")}
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
icon={MonitorCog}
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.homesDisk")}
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
icon={HardDrive}
trend={
homesDisk
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
: undefined
}
help={t("server.homesDiskHelp")}
loading={loading}
/>
</div>
<div className="card overflow-x-auto">
<div className="px-5 pt-4 pb-2">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
<p className="text-xs text-fg-muted">
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
</p>
</div>
{stats && !stats.usage ? (
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
) : (
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.who")}</th>
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
<th className={`${th} text-right`}>{t("server.processes")}</th>
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && (
<tr>
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
{t("server.sampling")}
</td>
</tr>
)}
{stats?.usage?.groups.map((g) => {
const u = g.userId != null ? byId.get(g.userId) : undefined;
const home = g.userId != null && storage ? storage[g.userId] : undefined;
const isOpen = open.has(g.key);
const name = groupName(g);
return (
<Fragment key={g.key}>
<tr className="hover:bg-surface-4/40 transition-colors">
<td className="px-5 py-3">
<button
type="button"
onClick={() => toggle(g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex items-center gap-2 text-left disabled:cursor-default"
>
{g.top.length ? (
isOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
)
) : (
<span className="w-3.5" />
)}
{g.kind === "user" ? (
<UserAvatar
name={name}
avatarAt={u?.avatarAt}
userId={g.userId}
className="h-6 w-6 text-[10px]"
/>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
<Server className="w-3.5 h-3.5 text-fg-muted" />
</span>
)}
<span className="min-w-0">
<span className="block text-sm text-fg-primary truncate">{name}</span>
{g.kind === "user" && u?.displayName && (
<span className="block text-[11px] text-fg-muted">{g.username}</span>
)}
</span>
</button>
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(g.memory)}{" "}
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
</div>
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{g.processes}
</td>
<td className="px-5 py-3">
{home ? (
<>
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(home.bytes)}
{homesDisk && (
<span className="text-fg-muted">
{" "}
({fmtPct(pct(home.bytes, homesDisk.total))})
</span>
)}
</div>
{homesDisk && (
<Meter
value={pct(home.bytes, homesDisk.total)}
label={`${name} ${t("server.home")}`}
/>
)}
</>
) : (
<span className="text-sm text-fg-muted">—</span>
)}
</td>
</tr>
{isOpen &&
g.top.map((p) => (
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{fmtPct(p.cpu)}
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{formatBytes(p.memory)}
</td>
<td colSpan={2} />
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{stats && stats.disks.length > 0 && (
<div className="card overflow-x-auto">
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
{t("server.disks")}
</h3>
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.mount")}</th>
<th className={th}>{t("server.device")}</th>
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
<th className={`${th} text-right`}>{t("server.free")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{stats.disks.map((d) => (
<tr key={d.device}>
<td className="px-5 py-3 text-sm text-fg-primary">
<span className="font-mono">{d.mount}</span>
{d.mount === stats.homesMount && (
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
)}
</td>
<td className="px-5 py-3 text-xs text-fg-secondary">
<span className="font-mono">{d.device}</span> · {d.type}
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
</div>
<Meter value={pct(d.used, d.total)} label={d.mount} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{formatBytes(d.free)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{stats && stats.gpus.length > 0 && (
<div className="card p-5 space-y-3">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
{stats.gpus.map((g, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-fg-primary">{g.name}</span>
<span className="text-fg-muted tabular-nums">
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
{formatBytes(g.memoryTotal)}
</span>
</div>
<Meter value={g.utilization} label={g.name} />
</div>
))}
</div>
)}
<MemberTmuxCard isAdmin={isAdmin} />
</div>
);
}
@@ -14,6 +14,7 @@
* and tick the ones to add; or type `name@marketplace` by hand, which is
* also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,8 +25,8 @@ import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const label = "block text-[11px] font-medium text-fg-secondary";
const field = "input w-full";
const label = "block text-xs text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
);
}
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
const clean = text.replace(/^\uFEFF/, "");
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
return {
name: frontmatterField(clean, "name"),
description: frontmatterField(clean, "description"),
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
};
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
export function SkillNameField({
value,
onChange,
disabled,
@@ -175,7 +187,7 @@ function SkillNameField({
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={value}
disabled={disabled}
placeholder="release-notes"
@@ -296,7 +308,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
className={`${field} mt-1`}
rows={2}
value={description}
disabled={many}
@@ -310,7 +322,7 @@ export function SkillAddPanel({
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
@@ -452,7 +464,10 @@ export function SkillAddPanel({
/>
)}
{meta}
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
@@ -489,7 +504,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
className={`${field} mt-1`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
@@ -499,7 +514,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
className={`${field} mt-1 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
@@ -509,12 +524,15 @@ export function SkillAddPanel({
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
@@ -534,7 +552,7 @@ export function SkillAddPanel({
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
className="select w-full"
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
@@ -696,7 +714,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<div className="mt-1 flex gap-2">
<input
className={field}
value={link}
@@ -777,7 +795,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
</li>
))}
</ul>
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="btn-ghost text-sm"
onClick={onClose}
disabled={busy}
>
{t("form.cancel")}
</button>
<button
type="button"
className="btn-primary text-sm"
@@ -853,7 +879,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
@@ -902,7 +931,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
@@ -0,0 +1,276 @@
/**
* @file AgentDetails.tsx
* @description What the chat shows of a subagent, like Claude Desktop: an
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
* the task it was given and the report it returned — read off the call, its
* result (`tool_result.agent`) and, for a background agent, the
* `<task-notification>` that closed it — and, one click further, the
* subagent's own conversation as a small chat right there in the step, every
* step of it. The chat's owner (ConversationView) supplies the links through
* `AgentContext`: which agent transcript a call launched, the notifications
* seen, and how to render one agent's conversation (a nested
* ConversationView — passed in, so this module never imports it back).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { createContext, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
import { fmt, formatModelName, formatMs } from "../../lib/format";
import { MarkdownContent } from "./MarkdownContent";
export type AgentStatus = "running" | "done" | "error" | "stopped";
/** One subagent as the chat knows it from the call that launched it. */
export interface AgentRef {
/** The Agent/Task `tool_use` id. */
toolUseId: string;
/** Its own transcript's `agent_id`; null while nothing names it yet. */
agentId: string | null;
type: string;
description: string;
model: string | null;
status: AgentStatus;
background: boolean;
prompt: string | null;
report: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
/** What a `<task-notification>` said about a background agent. */
export interface AgentNotice {
status: string;
result: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
export interface AgentLinks {
/** tool_use id → agent transcript id, from the session's transcript list. */
ids: Map<string, string>;
/** tool_use id → the notification that closed that background agent. */
notices: Map<string, AgentNotice>;
/** The session runs in a pane: a call with no result yet still works. */
live: boolean;
/** One agent's own conversation, as a chat that fills its box. */
render?: (agent: AgentRef) => React.ReactNode;
}
export const AgentContext = createContext<AgentLinks>({
ids: new Map(),
notices: new Map(),
live: false,
});
const AGENT_TOOLS = new Set(["Agent", "Task"]);
export const isAgentCall = (b: TranscriptContent) =>
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
// The result names the agent's own transcript (`agent-<id>.jsonl`).
const AGENT_ID = /agentId:\s*([\w-]+)/;
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
const tag = (text: string, name: string) =>
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
/** The time, tokens and tool calls of a `<usage>` block, in either form
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
const body = text.match(USAGE)?.[1] ?? "";
const num = (...names: string[]) => {
for (const n of names) {
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
if (v) return Number(v);
}
return null;
};
return {
durationMs: num("duration_ms"),
tokens: num("subagent_tokens", "total_tokens"),
toolUses: num("tool_uses"),
};
}
/** Each `<task-notification>` in the transcript, by the call it closes. */
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
const out = new Map<string, AgentNotice>();
for (const msg of messages) {
if (msg.type !== "user") continue;
for (const c of msg.content) {
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
for (const part of c.text.split("<task-notification>").slice(1)) {
const id = tag(part, "tool-use-id");
if (!id) continue;
out.set(id, {
status: tag(part, "status") ?? "completed",
result: tag(part, "result"),
...parseUsage(part),
});
}
}
}
return out;
}
/** A report without the footer Claude Code appends for the orchestrator. */
function stripFooter(text: string): string {
return text
.replace(USAGE, "")
.replace(/^agentId:.*$/m, "")
.trim();
}
/** Everything the chat can say about the subagent `block` launched. */
export function agentRef(
block: TranscriptContent,
result: TranscriptContent | null | undefined,
links: AgentLinks
): AgentRef {
const raw = block.input as Record<string, unknown> | undefined;
const input = raw && !("_truncated" in raw) ? raw : {};
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
const id = block.id ?? "";
const facts = result?.agent;
const output = result?.output ?? "";
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
const notice = background ? links.notices.get(id) : undefined;
const usage = !background && output ? parseUsage(output) : null;
let status: AgentStatus;
if (result?.is_error) status = "error";
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
else if (notice && notice.status !== "completed")
status = notice.status === "failed" ? "error" : "stopped";
else status = "done";
return {
toolUseId: id,
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
type: str(input.subagent_type) ?? "Agent",
description: str(input.description) ?? "",
model: facts?.model ?? str(input.model),
status,
background,
prompt: str(input.prompt),
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
};
}
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
};
/** One muted line: its status as an icon, then type · model · time ·
* tokens · tool calls, as far as they are known. */
export function AgentFacts({ agent }: { agent: AgentRef }) {
const { t } = useTranslation("sessions");
const parts = [
agent.type,
agent.model && formatModelName(agent.model),
agent.background && t("conversation.agent.background"),
agent.durationMs != null && formatMs(agent.durationMs),
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
].filter(Boolean);
return (
<div
data-testid="agent-facts"
title={t(`conversation.agent.status.${agent.status}`)}
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
>
{STATUS_ICON[agent.status]}
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
<span className="truncate">{parts.join(" · ")}</span>
</div>
);
}
const linkClass =
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
/** The subagent's own conversation, a thread hung under its step by a line
* on the left — as tall as it is, up to 24rem, then it scrolls. */
export function AgentChatBox({ agent }: { agent: AgentRef }) {
const links = useContext(AgentContext);
return (
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
{links.render?.(agent)}
</div>
);
}
/** An Agent/Task step opened in the chat: one line of facts, its report as
* prose, and "View conversation ›", which puts the agent's own thread (its
* task, every step, its reply) in the report's place. With no transcript
* to show, the task it was given opens on its own instead. */
export function AgentStepBody({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const links = useContext(AgentContext);
const agent = agentRef(block, result, links);
const canOpen = !!agent.agentId && !!links.render;
const [thread, setThread] = useState(false);
const [task, setTask] = useState(false);
return (
<div data-testid="agent-step" className="space-y-1.5">
<AgentFacts agent={agent} />
{thread ? (
<AgentChatBox agent={agent} />
) : (
<>
{!canOpen && agent.prompt && task && (
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
<MarkdownContent text={agent.prompt} dense />
</div>
)}
{agent.report && (
<div
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
>
<MarkdownContent text={agent.report} dense />
</div>
)}
</>
)}
{canOpen ? (
<button
type="button"
data-testid="agent-open"
aria-expanded={thread}
onClick={() => setThread(!thread)}
className={linkClass}
>
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
</button>
) : (
agent.prompt && (
<button
type="button"
aria-expanded={task}
onClick={() => setTask(!task)}
className={linkClass}
>
{t("conversation.agent.prompt")}
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
</button>
)
)}
</div>
);
}
@@ -14,6 +14,12 @@
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once. The main
* transcript hands MessageList its `sessionId`, so the list can read its
* background agents' own transcripts for their progress.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -67,6 +73,7 @@ import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList, promptTokens } from "./MessageList";
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
@@ -79,6 +86,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
// hook event. A short visibility-gated poll closes that gap and also rescues
// the conversation from missed/late WebSocket frames.
const POLL_INTERVAL_MS = 3000;
// An ended session writes nothing more: a slow safety poll only, in case it was
// resumed somewhere whose frames this view missed.
const ENDED_POLL_INTERVAL_MS = 60000;
// Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000;
@@ -93,6 +103,9 @@ interface ConversationViewProps {
/** 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;
/** A subagent's conversation inside its step: no toolbar, as tall as its
* content up to 24rem, then it scrolls. */
compact?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session runs in a pane: show the step in progress. */
@@ -110,12 +123,15 @@ interface ConversationViewProps {
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
/** The session has ended (and no pane holds it): poll slowly, if at all. */
ended?: boolean;
}
function ConversationViewPane({
sessionId,
initialTranscriptId,
fill = false,
compact = false,
onOpenFile,
live = false,
follow = 0,
@@ -124,6 +140,7 @@ function ConversationViewPane({
stopped = 0,
onContext,
onNotStarted,
ended = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -174,6 +191,27 @@ function ConversationViewPane({
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
// A subagent's own conversation opens inside its step as a small chat: this
// same view, on that agent's transcript.
const renderAgent = useCallback(
(a: AgentRef) => (
<ConversationView
key={a.agentId}
sessionId={sessionId}
initialTranscriptId={a.agentId}
compact
live={live && a.status === "running"}
ended={a.status !== "running"}
/>
),
[sessionId, live]
);
const agentLinks = useMemo<AgentLinks>(() => {
const ids = new Map<string, string>();
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
return { ids, notices: agentNotices(messages), live, render: renderAgent };
}, [transcripts, messages, live, renderAgent]);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
useEffect(() => {
@@ -188,14 +226,18 @@ function ConversationViewPane({
}
}
loadTranscripts();
const interval = window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
// An ended session spawns no new sub-agents: the load above (re-run when
// it ends, for the last ones) is enough.
const interval = ended
? undefined
: window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [sessionId]);
}, [sessionId, ended]);
// Sync external initialTranscriptId to internal state
useEffect(() => {
@@ -355,9 +397,12 @@ function ConversationViewPane({
let interval: number | null = null;
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (isShown(onScreen)) fetchNewMessages();
}, POLL_INTERVAL_MS);
interval = window.setInterval(
() => {
if (isShown(onScreen)) fetchNewMessages();
},
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
);
}
function stop() {
if (interval !== null) {
@@ -382,7 +427,7 @@ function ConversationViewPane({
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages, onScreen]);
}, [fetchNewMessages, onScreen, ended]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
@@ -499,7 +544,7 @@ function ConversationViewPane({
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
{!loading && !fill && (
{!loading && !fill && !compact && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && (
<div className="relative">
@@ -547,7 +592,13 @@ function ConversationViewPane({
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
style={
compact
? { maxHeight: "24rem" }
: fill
? { minHeight: 0 }
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
}
>
{/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
@@ -570,7 +621,9 @@ function ConversationViewPane({
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
<div
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
>
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
@@ -605,6 +658,11 @@ function ConversationViewPane({
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
agentLinks={agentLinks}
compact={compact}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
/>
)}
</div>
@@ -631,7 +689,7 @@ function ConversationViewPane({
)}
{/* New messages indicator */}
{showNewMsg && !fill && (
{showNewMsg && !fill && !compact && (
<button
onClick={() => {
scrollToBottom();
@@ -4,7 +4,7 @@
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. A file path — absolute, or relative with a folder and
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
* inline code or as a markdown link's target — opens through `OpenFileContext`
* when the page provides one (the page resolves a relative one against its own
@@ -272,8 +272,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
const REL_PATH_RE =
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
const WIN_PATH_RE =
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
// In inline code the whole text is the path, so a Windows one may hold spaces.
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
/** A path at the start of `text`, absolute or relative, or null. */
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
const matchPath = (text: string) =>
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
// A markdown link target that is a page, not a file.
@@ -393,7 +399,13 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
{codeM[1]}
</code>
);
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
push(
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
)
);
i += codeM[0].length;
continue;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
/**
* @file AgentDetails.test.tsx
* @description Verifies what the chat shows of a subagent: an Agent step's
* facts, task and report (from its result, or the task-notification that
* closed a background one), and that "View conversation" opens that
* agent's own conversation in place, rendered by the chat's owner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageList } from "../MessageList";
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
const call: TranscriptContent = {
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
};
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
ids: new Map(),
notices: new Map(),
live: false,
...p,
});
describe("agentRef", () => {
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
const a = agentRef(
call,
{
type: "tool_result",
id: "t1",
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
agent: {
id: "a1b2",
model: "claude-opus-5-5",
status: "completed",
duration_ms: 65_000,
tokens: 12_000,
tool_uses: 7,
},
},
links()
);
expect(a).toMatchObject({
agentId: "a1b2",
type: "Explore",
status: "done",
background: false,
prompt: "Read **server/**",
report: "The hooks live in server/routes/hooks.js.",
durationMs: 65_000,
tokens: 12_000,
toolUses: 7,
});
});
it("closes a background agent with its notification's report and usage", () => {
const notice = m({
content: [
{
type: "text",
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
},
],
});
const launched: TranscriptContent = {
type: "tool_result",
id: "t1",
output: "Async agent launched successfully.\nagentId: a9b4",
};
const running = agentRef(call, launched, links({ live: true }));
expect(running).toMatchObject({ status: "running", background: true, report: null });
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
expect(done).toMatchObject({
agentId: "a9b4",
status: "done",
report: "All done",
tokens: 88007,
toolUses: 31,
durationMs: 132350,
});
});
it("finds the transcript from the session's list when the result names none", () => {
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
});
it("parses the line form of a usage block", () => {
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
tokens: 500,
toolUses: 3,
durationMs: 900,
});
});
});
describe("an Agent step in the chat", () => {
it("shows the agent's facts and report, and opens its conversation in place", () => {
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
render(
<MessageList
loading={false}
agentLinks={links({ render: render_ })}
messages={[
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
m({
content: [
{
type: "tool_result",
id: "t1",
output: "Found **3** hooks",
agent: {
id: "a1b2",
model: null,
status: "completed",
duration_ms: null,
tokens: null,
tool_uses: 4,
},
},
],
}),
]}
/>
);
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
expect(screen.queryByTestId("agent-chat")).toBeNull();
fireEvent.click(screen.getByTestId("agent-open"));
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
// The thread takes the report's place.
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
});
});
@@ -36,6 +36,20 @@ describe("file links", () => {
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
});
it("opens a Windows path, bare or in inline code", () => {
const open = withOpen(
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
);
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
expect(open.mock.calls).toEqual([
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
["C:/src/a.ts"],
["D:\\My Docs\\b.md"],
]);
});
it("opens a markdown link to a file instead of navigating", () => {
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
@@ -0,0 +1,118 @@
/**
* @file MessageList.agents.test.tsx
* @description Verifies a live chat's background-agents card reads each
* running agent's own transcript (by the agentId its launch result names)
* and shows the step it is on and its context size (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageList, agentProgress } from "../MessageList";
import { api } from "../../../lib/api";
import type { TranscriptMessage } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
});
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
// The subagent's own transcript: its task, then a search still running.
const tail: TranscriptMessage[] = [
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
content: [
{
type: "tool_use",
id: "g1",
name: "Grep",
input: { pattern: "copyId", description: "Searching for copyId" },
},
],
}),
];
const main: TranscriptMessage[] = [
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
}),
m({
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
]),
},
],
}),
m({
type: "assistant",
sender: "assistant",
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting" }],
}),
];
describe("background agents' progress", () => {
const rects = Element.prototype.getClientRects;
beforeEach(() => {
// jsdom lays nothing out: make the card count as on screen.
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
vi.mocked(api.sessions.transcript).mockResolvedValue({
messages: tail,
total: 2,
has_more: false,
first_line: 1,
last_line: 2,
});
});
afterEach(() => {
Element.prototype.getClientRects = rects;
vi.mocked(api.sessions.transcript).mockReset();
});
it("reads the step an agent is on and its context", () => {
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
});
it("shows them on the agent's line, from its own transcript", async () => {
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
await waitFor(() =>
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
"Searching for copyId"
)
);
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
agent_id: "a4118367b503adecc",
limit: 20,
});
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
});
it("reads nothing without the session", () => {
render(<MessageList live loading={false} messages={main} />);
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
expect(api.sessions.transcript).not.toHaveBeenCalled();
});
});
@@ -5,12 +5,13 @@
* 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; a typed `/command` or `@path`
* shows as a chip.
* shows as a chip. A live chat lists the background subagents still running
* and does not call their turn done (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -137,7 +138,28 @@ describe("MessageList — sender attribution", () => {
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
// In the hover bar's tooltip, beside the model and time.
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
});
it("shows a local command as one line with its output, never its caveat", () => {
const line = (text: string, sender: "user" | "system" = "user") =>
msg({ type: "user", sender, content: [{ type: "text", text }] });
render(
<MessageList
loading={false}
messages={[
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
line("<command-name>/model</command-name><command-args>opus</command-args>"),
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
]}
/>
);
const row = screen.getByTestId("chat-command");
expect(row).toHaveTextContent("/model opus");
expect(row).toHaveTextContent("Set model to Opus");
expect(screen.queryByText(/Caveat/)).toBeNull();
expect(screen.queryByTestId("chat-user")).toBeNull();
});
it("ends a live chat with the step still running", () => {
@@ -167,7 +189,9 @@ describe("MessageList — sender attribution", () => {
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite");
// Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
// (the output shows on hover).
expect(row).toHaveTextContent(/1m \ds/);
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
@@ -340,7 +364,88 @@ describe("MessageList — sender attribution", () => {
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// A local command (`/clear`) is answered by the CLI itself: no turn.
const local = (text: string) =>
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
const clear = local("<command-name>/clear</command-name>");
const out = local("<local-command-stdout></local-command-stdout>");
expect(state([clear, out])).toBeNull();
// A prompt command (a skill) is still answered by the model.
expect(state([clear])).toEqual(thinking);
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
describe("background subagents", () => {
const at = new Date().toISOString();
const launch = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
});
const launched = msg({
timestamp: at,
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
},
],
});
const reply = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting for the agent" }],
});
const notified = msg({
sender: "system",
timestamp: at,
content: [
{
type: "text",
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
},
],
});
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
it("lists an agent until its notification names the call", () => {
const results = (m: TranscriptMessage[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
const all = [...base, notified];
expect(backgroundAgents(all, results(all))).toEqual([]);
});
it("does not call the turn done while the agent works", () => {
const { rerender } = render(<MessageList live loading={false} messages={base} />);
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
"ExploreChecking copyId coverage"
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
});
it("shows nothing once the session is no longer live", () => {
render(<MessageList loading={false} messages={base} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
});
});
});
+30 -1
View File
@@ -9,7 +9,8 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git). There is no AI tool picker: the lane is created with the
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
@@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -116,6 +118,9 @@ export function AddLaneModal({
// server asked for one — a login for the URL's https host.
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -159,6 +164,7 @@ export function AddLaneModal({
setMode("repo");
setCloneUrl("");
setCloneName("");
setClonePartial(false);
setLogin(null);
setSshRefused(false);
setSourceRepo("");
@@ -236,13 +242,17 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
try {
const result = await api.lanes.clone({
progressToken,
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
@@ -274,6 +284,8 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
} finally {
setCloneToken(null);
}
return;
}
@@ -547,6 +559,23 @@ export function AddLaneModal({
spellCheck={false}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<label
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="add-lane-clone-partial"
checked={clonePartial}
onChange={(e) => setClonePartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
</div>
)}
@@ -0,0 +1,224 @@
/**
* @file CloneHereDialog.tsx
* @description The Files page's **New → Clone repository**: clones a URL into
* the folder the File Explorer has open, through the same
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
type Login = { host: string; username: string; token: string; remember: boolean };
export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [login, setLogin] = useState<Login | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useDialog(open, panelRef, busy ? () => {} : onClose);
useEffect(() => {
if (!open) return;
setUrl("");
setName("");
setLogin(null);
setSshRefused(false);
setBusy(false);
setError(null);
}, [open, parent]);
if (!open) return null;
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
setBusy(true);
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onCloned(result.lane, result.path);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
token: "",
remember: l?.remember ?? true,
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
} else {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
}
};
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={busy ? undefined : onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="fx-clone-dialog"
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="space-y-3"
>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
{t("clone.urlLabel")}
</label>
<input
id={`${titleId}-url`}
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://host/owner/repo.git"
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
{t("clone.nameLabel")}
</label>
<input
id={`${titleId}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={cloneDirName(url)}
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 break-all text-[11px] text-fg-muted">
{t("clone.parentHint", { path: into })}
</p>
</div>
{login && (
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="username"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<input
aria-label={t("clone.token")}
placeholder={t("clone.token")}
type="password"
autoComplete="current-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
disabled={busy}
/>
{t("clone.remember")}
</label>
</div>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
{sshRefused && (
<a href="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
)}
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
{t("fx.cancel")}
</button>
<button
type="submit"
disabled={busy || !url.trim()}
className="btn-primary text-xs"
data-testid="fx-clone-submit"
>
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
</button>
</div>
</form>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,93 @@
/**
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
const POLL_MS = 1000;
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
</p>
);
}
const phase = view.phase
? view.phase === "other"
? view.label || ""
: t(`clone.phase.${view.phase}`)
: t("clone.connecting");
const percent = view.percent ?? 0;
return (
<div data-testid="clone-progress" className="space-y-1">
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
<span className="truncate">
{phase}
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
)}
</p>
{view.phase && (
<div
className="h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={phase}
>
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
</div>
)}
</div>
);
}
@@ -4,18 +4,62 @@
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
* added as an extra folder, one after another. A host that wants a login
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
* URLs with it, like Add workspace → Clone.
* URLs with it, like Add workspace → Clone. Before anything runs, each URL
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
* link while the account has no SSH key is not cloned at all — the user is
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type LaneSourcesView } from "../../lib/api";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
type Row = {
url: string;
state: "wait" | "busy" | "done" | "failed";
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
try {
const u = new URL(url);
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
} catch {
/* not a URL yet — the server's allowlist answers that one */
}
return null;
}
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
function missingFor(
url: string,
id: GitIdentity | null
): { kind: "ssh" | "https"; host: string } | null {
const target = cloneTarget(url);
if (!id || !target) return null;
if (target.kind === "ssh") {
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
return keys > 0 ? null : target;
}
return id.https.some((h) => h.host === target.host) ? null : target;
}
export function CloneSourcesForm({
laneId,
parent,
@@ -31,6 +75,25 @@ export function CloneSourcesForm({
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null);
const [partial, setPartial] = useState(false);
const [identity, setIdentity] = useState<GitIdentity | null>(null);
// An unknown identity (an older server, a failed read) checks nothing: the
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
const loadIdentity = useCallback(
() =>
Promise.resolve()
.then(() => api.gitIdentity.get())
.then(setIdentity)
.catch(() => {}),
[]
);
useEffect(() => {
void loadIdentity();
const onFocus = () => void loadIdentity();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [loadIdentity]);
const urls = (s: string) => [
...new Set(
@@ -44,16 +107,27 @@ export function CloneSourcesForm({
const cloneAll = async (list: string[]) => {
if (busy || !list.length) return;
setBusy(true);
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
// An SSH link with no key on the account would only fail at the host:
// it is not tried, and its row says what to set up first.
const next: Row[] = list.map((url) => {
const miss = missingFor(url, identity);
return miss?.kind === "ssh"
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
: { url, state: "wait" };
});
const show = () => setRows([...next]);
show();
let asked: Login | null = null;
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
@@ -80,10 +154,12 @@ export function CloneSourcesForm({
};
} else if (e.code === "ESSHAUTH") {
row.note = t("clone.sshRefused", { host });
row.ssh = true;
} else {
row.note = e.message || String(err);
}
}
row.token = undefined;
show();
}
if (asked) setLogin(asked);
@@ -99,6 +175,18 @@ export function CloneSourcesForm({
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
// Checked as the user types, so a missing key is said before Clone is pressed.
const missing = urls(text)
.map((u) => missingFor(u, identity))
.filter((m): m is NonNullable<typeof m> => m !== null);
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
const settingsLink = (
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
{t("clone.sshSettings")}
</a>
);
return (
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
@@ -112,6 +200,41 @@ export function CloneSourcesForm({
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
<label
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="workspace-clone-partial"
checked={partial}
onChange={(e) => setPartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{sshHosts.length > 0 && (
<p
role="alert"
data-testid="workspace-clone-need-ssh"
className="text-[11px] text-status-danger"
>
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
</p>
)}
{httpsHosts.length > 0 && !login && (
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
<button
type="button"
className="underline"
onClick={() =>
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
}
>
{t("home.cloneEnterLogin")}
</button>
</p>
)}
{login && (
<div className="space-y-1.5 rounded border border-border-light p-2">
<div className="flex gap-2">
@@ -178,7 +301,17 @@ export function CloneSourcesForm({
{r.url}
</span>
</span>
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
{r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
{r.note}
{r.ssh && <> {settingsLink}</>}
</span>
)}
</li>
))}
</ul>
+562 -46
View File
@@ -18,7 +18,12 @@
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip; a taken name asks Replace / Keep both /
* Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js).
* Skip. Deleting forever asks first when it empties the Trash or touches
* another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge
* without waiting the 7 days.
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
* (server/lib/file-explorer.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -82,12 +87,52 @@ import {
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format";
import { copyText } from "../../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CloneHereDialog } from "./CloneHereDialog";
/** One upload batch at most (the server caps one file the same). */
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
/** Files up to this size travel together, many per request (`uploadBatch`):
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
const SMALL_FILE = 4 * 1024 * 1024;
const PACK_BYTES = 32 * 1024 * 1024;
const PACK_FILES = 1000;
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
const PACK_NAME_BYTES = 400 * 1024;
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
const packs: T[][] = [];
const singles: T[] = [];
let cur: T[] = [];
let bytes = 0;
let names = 0;
for (const f of files) {
if (f.file.size > SMALL_FILE) {
singles.push(f);
continue;
}
const n = f.rel.length * 3 + 64;
if (
cur.length &&
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
) {
packs.push(cur);
cur = [];
bytes = 0;
names = 0;
}
cur.push(f);
bytes += f.file.size;
names += n;
}
if (cur.length) packs.push(cur);
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
return { packs: packs.filter((p) => p.length >= 2), singles };
}
const MIME = "application/x-agenthub-fx";
const VIEW_KEY = "agenthub.files.view";
const HIDDEN_KEY = "agenthub.files.hidden";
@@ -96,6 +141,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
let clipboard: Clip | null = null;
/** A menu item's handler that closes its menu first. */
const pick = (m: { close: () => void }, fn: () => void) => () => {
m.close();
fn();
};
/** The workspace id of a place, or null for a home. */
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
@@ -250,9 +300,14 @@ export function FileBrowser({
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
const [query, setQuery] = useState<string | null>(null);
const [trash, setTrash] = useState<FsTrashItem[]>([]);
// An admin's Trash opens on its root: one folder per user's Trash, counted
// from `GET /api/homes/trash` (a Trash that could not be read: its error).
const [trashRoot, setTrashRoot] = useState(false);
const [trashErrors, setTrashErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
const [cloning, setCloning] = useState(false);
const [ask, setAsk] = useState<Ask | null>(null);
const [askAll, setAskAll] = useState(false);
const [progress, setProgress] = useState<{
@@ -269,6 +324,9 @@ export function FileBrowser({
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const ctx = useContextMenu<FsEntry | null>();
// The sidebar's places and the path bar's crumbs have their own menus.
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
const crumbCtx = useContextMenu<string>();
// A new caller target (another workspace's Files tab, the page's URL) —
// not the echo of our own `go()` through the page's URL, which would wipe
@@ -296,6 +354,22 @@ export function FileBrowser({
}, []);
// Another user's home (an admin's view) is read-only.
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
// Clone into the open folder: the absolute path of a workspace's own folder
// or of the user's own home (never a session worktree, an extra folder or
// another user's home); the server checks the folder again.
const cloneRoot = isHomePlace(target)
? target.home === "me"
? (homes.find((h) => h.id === "me")?.dir ?? null)
: null
: typeof target === "number"
? (lanes.find((l) => l.id === target)?.cwd ?? null)
: null;
const cloneParent =
cloneRoot && view === "files"
? path
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
: cloneRoot
: null;
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
const go = (p: string, tgt: FsPlace = target) => {
@@ -330,7 +404,12 @@ export function FileBrowser({
setError(null);
setLoading(true);
try {
if (view === "trash") setTrash((await api.files.trash(target)).items);
if (view === "trash" && trashRoot) {
const out = await api.files.allTrash();
setTrash(out.items);
// A user whose trash could not be read is said so on their folder.
setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message])));
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
else setListing(await api.files.list(target, path));
} catch (e) {
// A dashboard that does not know /fs yet (not restarted) answers 404.
@@ -338,10 +417,11 @@ export function FileBrowser({
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
);
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
if (view === "trash") setTrash([]);
} finally {
setLoading(false);
}
}, [target, path, view, t]);
}, [target, path, view, trashRoot, t]);
useEffect(() => {
void load();
}, [load]);
@@ -518,32 +598,95 @@ export function FileBrowser({
}
setBusy(true);
let sent = 0;
let done = 0;
let saved = 0;
let forAll: FsConflict | null = null;
try {
for (let i = 0; i < files.length; i++) {
const { file, rel } = files[i]!;
const dest = join(dir, rel);
setProgress({ name: rel, done: i, total: files.length, sent, bytes });
const send = (c: FsConflict) =>
api.files.upload(target, dest, file, c, (n) =>
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
);
try {
const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e;
const a = await askConflict([rel], files.length > 1);
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
if (a.all) forAll = a.choice;
return send(a.choice);
});
if (!r.skipped) saved++;
} catch (e) {
if (errCode(e) === "ECANCEL") break;
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
}
sent += file.size;
// false once the server says a batch cannot be written here (an old helper).
let batchable = true;
const show = (name: string, extra = 0) =>
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
/** One file; false = the user cancelled. */
const one = async ({ file, rel }: { file: File; rel: string }) => {
const dest = join(dir, rel);
show(rel);
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
try {
const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e;
const a = await askConflict([rel], files.length > 1);
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
if (a.all) forAll = a.choice;
return send(a.choice);
});
if (!r.skipped) saved++;
} catch (e) {
if (errCode(e) === "ECANCEL") return false;
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
}
sent += file.size;
done++;
return true;
};
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
const pack = async (b: { file: File; rel: string }[]) => {
const label = `${b[0]!.rel} (+${b.length - 1})`;
const size = b.reduce((n, f) => n + f.file.size, 0);
show(label);
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
const choose = async (taken: string[]) => {
if (forAll) return forAll;
if (!taken.length) return "fail" as const;
const a = await askConflict(taken, files.length > 1);
if (a.all && a.choice) forAll = a.choice;
return a.choice;
};
try {
const c = await api.files.uploadCheck(
target,
dir,
b.map((f) => f.rel)
);
if (!c.batch) {
batchable = false;
return "fallback";
}
let policy = await choose(c.taken);
if (!policy) return "cancel";
const send = (p: FsConflict) =>
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
const r = await send(policy).catch(async (e) => {
// Taken meanwhile: ask about those, then once more.
if (errCode(e) !== "EEXISTS") throw e;
policy = await choose((e as { names?: string[] }).names ?? []);
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
return send(policy);
});
saved += r.saved;
} catch (e) {
const code = errCode(e);
if (code === "ECANCEL") return "cancel";
// Refused before a byte was written (a bad name, an old helper…):
// each file then gets its own answer.
if (code === "EUPGRADE") batchable = false;
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
return "fallback";
}
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
}
sent += size;
done += b.length;
return "ok";
};
try {
const { packs, singles } = packUploads(files);
let go = true;
for (const b of packs) {
const r = batchable ? await pack(b) : "fallback";
if (r === "cancel") go = false;
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
if (!go) break;
}
for (const f of singles) if (!go || !(await one(f))) break;
} finally {
setProgress(null);
setBusy(false);
@@ -609,7 +752,7 @@ export function FileBrowser({
writePref(VIEW_KEY, l);
};
const onKey = (e: ReactKeyboardEvent) => {
if (renaming || creating || ask) return;
if (renaming || creating || ask || cloning) return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
// Another user's home is read-only: no key that writes.
@@ -635,11 +778,82 @@ export function FileBrowser({
const homeName = (who: string) =>
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
/** Delete forever — asked first when it is all of it or someone else's. */
const purge = async (id: string) => {
if (id === "all" || ro) {
const item = trash.find((i) => i.id === id);
const ok = await confirmDialog({
title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"),
message:
id === "all"
? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel })
: t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }),
confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"),
});
if (!ok) return;
}
const item = trash.find((i) => i.id === id);
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
};
/** Empty one user's whole Trash from the Trash root — asked first. */
const emptyTrashOf = async (home: string, count: number) => {
const ok = await confirmDialog({
title: t("fx.emptyTrashTitle"),
message: t("fx.emptyTrashBody", {
count,
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
}),
confirmLabel: t("fx.emptyTrash"),
});
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
};
const restoreItem = (id: string) => {
const item = trash.find((i) => i.id === id);
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
};
/**
* Open a Trash. Every user has ONE recycle bin — their home's, over every
* place they deleted in — so `home` opens that user's ("me": the caller's);
* null (no home to open) falls back to the trash of the place shown, and
* "*" opens the root an admin starts on: one folder per user's Trash.
*/
const openTrash = (home: string | null) => {
if (home === "*") {
setTrashRoot(true);
setView("trash");
setSelected(new Set());
return;
}
setTrashRoot(false);
if (home && !(isHomePlace(target) && target.home === home)) {
setTarget({ home });
setPathState("");
setHistory({ back: [], fwd: [] });
onNavigate?.({ home }, "");
}
setView("trash");
setSelected(new Set());
};
// Someone else's Trash can be opened: the Trash starts on its folders.
const manyTrashes = homes.some((h) => h.id !== "me");
const openTrashes = () =>
openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null);
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
const laneName = (id: number) => {
const l = lanes.find((x) => x.id === id);
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
};
// A home's Trash is its USER's recycle bin, named after them: another
// user's shows THAT user's (an admin may restore from or purge it; the rest
// of the home stays read-only). A workspace's (no home to open) is the
// trash of whoever owns its folder.
const trashLabel = trashRoot
? t("fx.trash")
: !isHomePlace(target)
? t("fx.trashAt", { name: laneName(laneOf(target)!) })
: target.home === "me"
? t("fx.myTrash")
: t("fx.trashOf", { name: homeName(target.home) });
const crumbs = path ? path.split("/") : [];
const menuItem = ctx.menu?.item ?? null;
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
@@ -689,6 +903,19 @@ export function FileBrowser({
onClick={() => setMenu(null)}
data-testid="file-explorer"
>
{cloneParent && (
<CloneHereDialog
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
onCloned={(lane, at) => {
setCloning(false);
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
changed();
}}
/>
)}
{/* ── Places ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
@@ -697,6 +924,7 @@ export function FileBrowser({
<button
type="button"
onClick={() => go("", { home: "me" })}
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === "me"
)}
@@ -719,6 +947,7 @@ export function FileBrowser({
type="button"
title={t("fx.readOnlyHome")}
onClick={() => go("", { home: h.id })}
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === h.id
)}
@@ -739,6 +968,7 @@ export function FileBrowser({
type="button"
title={l.cwd}
onClick={() => go("", l.id)}
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
className={placeClass(
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)}
@@ -748,17 +978,10 @@ export function FileBrowser({
</button>
))}
</nav>
<div className="border-t border-border p-2">
<button
type="button"
onClick={() => {
setView("trash");
setSelected(new Set());
}}
className={placeClass(view === "trash")}
>
<Trash2 className="h-4 w-4" />
{t("fx.trash")}
<div className="border-t border-border p-2" data-testid="fx-trashes">
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
<Trash2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t("fx.trash")}</span>
</button>
</div>
</aside>
@@ -774,8 +997,8 @@ export function FileBrowser({
</HeadButton>
<HeadButton
label={t("fx.up")}
disabled={view !== "files" || listing?.parent == null}
onClick={goUp}
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
onClick={view === "trash" ? () => openTrash("*") : goUp}
>
<ArrowUp className="h-4 w-4" />
</HeadButton>
@@ -805,15 +1028,36 @@ export function FileBrowser({
aria-label={t("fx.location")}
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
>
{view === "trash" ? (
{view === "trash" && manyTrashes ? (
<>
<button
type="button"
onClick={() => openTrash("*")}
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
trashRoot ? "font-semibold" : "text-fg-secondary"
}`}
>
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
</button>
{!trashRoot && (
<span className="flex shrink-0 items-center">
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
<span className="px-2 py-1 text-sm font-semibold">
{isHomePlace(target) ? homeName(target.home) : trashLabel}
</span>
</span>
)}
</>
) : view === "trash" ? (
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
<Trash2 className="h-4 w-4" /> {trashLabel}
</span>
) : (
<>
<button
type="button"
onClick={() => go("")}
onContextMenu={(e) => crumbCtx.open(e, "")}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, "")}
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
@@ -833,6 +1077,7 @@ export function FileBrowser({
<button
type="button"
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
@@ -905,6 +1150,17 @@ export function FileBrowser({
>
{t("fx.newFile")}
</DropItem>
{cloneParent && (
<DropItem
icon={<FolderGit2 className="h-4 w-4" />}
onClick={() => {
setMenu(null);
setCloning(true);
}}
>
{t("fx.cloneRepo")}
</DropItem>
)}
</DropButton>
<DropButton
label={t("fx.upload")}
@@ -1169,12 +1425,26 @@ export function FileBrowser({
</div>
)}
</div>
) : trashRoot ? (
<TrashFolders
homes={homes}
items={trash}
errors={trashErrors}
error={error}
loading={loading}
homeName={homeName}
onOpen={openTrash}
onEmpty={(home, count) => void emptyTrashOf(home, count)}
onRefresh={() => void load()}
/>
) : (
<TrashView
items={trash}
error={error}
laneName={laneName}
onRestore={(id) => void run(() => api.files.restore(target, id), t("fx.restored"))}
onPurge={(id) => void run(() => api.files.purge(target, id))}
onRestore={restoreItem}
onPurge={(id) => void purge(id)}
onRefresh={() => void load()}
/>
)}
@@ -1322,6 +1592,11 @@ export function FileBrowser({
{t("fx.newFolder")}
</MenuItem>
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
{cloneParent && (
<MenuItem onSelect={close(() => setCloning(true))}>
{t("fx.cloneRepo")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={close(() => fileInput.current?.click())}>
{t("fx.uploadFiles")}
@@ -1350,6 +1625,78 @@ export function FileBrowser({
</ContextMenu>
)}
{placeCtx.menu && (
<ContextMenu
x={placeCtx.menu.x}
y={placeCtx.menu.y}
onClose={placeCtx.close}
testId="fx-place-menu"
>
{"lane" in placeCtx.menu.item
? (() => {
const l = placeCtx.menu.item.lane;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()
: (() => {
const home = (placeCtx.menu.item as { home: string }).home;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
{t("fx.openTrash")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{crumbCtx.menu && (
<ContextMenu
x={crumbCtx.menu.x}
y={crumbCtx.menu.y}
onClose={crumbCtx.close}
testId="fx-crumb-menu"
>
{(() => {
const dir = crumbCtx.menu.item;
const name = dir.split("/").pop() || placeName(target);
return (
<>
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
<MenuItem
onSelect={pick(crumbCtx, () =>
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
)}
>
{t("fx.downloadZip")}
</MenuItem>
{!ro && clip && (
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
{t("fx.pasteInto")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{ask && (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
@@ -1651,20 +1998,157 @@ function InlineName({
);
}
/**
* An admin's Trash root: one folder per user's Trash (theirs first), each
* with its item count — or why it could not be read. Opening one shows that
* user's Trash, where its items are restored or deleted forever.
*/
function TrashFolders({
homes,
items,
errors,
error,
loading,
homeName,
onOpen,
onEmpty,
onRefresh,
}: {
homes: FsHome[];
/** Every Trash's items, merged, each tagged with its `home`. */
items: FsTrashItem[];
/** Per home id: why its Trash could not be read. */
errors: Record<string, string>;
error: string | null;
loading: boolean;
homeName: (id: string) => string;
onOpen: (home: string) => void;
onEmpty: (home: string, count: number) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<string | null>();
const counts = new Map<string, number>();
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1);
const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
return (
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash-root"
onContextMenu={(e) => menu.open(e, null)}
>
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
{t("fx.trashFoldersHint")}
</div>
{error && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2 overflow-y-auto p-4">
{ordered.map((h) => {
const n = counts.get(h.id) ?? 0;
const failed = errors[h.id];
const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id);
return (
<button
key={h.id}
type="button"
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
onClick={() => onOpen(h.id)}
onContextMenu={(e) => menu.open(e, h.id)}
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
data-testid="fx-trash-folder"
>
<span className="relative">
<Folder
className={`h-14 w-14 ${
n ? "fill-accent/25 text-accent" : "fill-surface-3 text-fg-muted"
}`}
/>
{h.id === "me" ? (
<House className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
) : (
<UserRound className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
)}
</span>
<span className="w-full truncate text-[13px] font-medium">{name}</span>
<span
className={`w-full truncate text-[11px] ${
failed ? "text-status-danger" : "text-fg-muted"
}`}
title={failed}
>
{failed
? failed
: loading
? "…"
: n
? t("fx.itemsCount", { count: n })
: t("fx.trashFolderEmpty")}
</span>
</button>
);
})}
</div>
{menu.menu && (
<ContextMenu
x={menu.menu.x}
y={menu.menu.y}
onClose={menu.close}
testId="fx-trash-root-menu"
>
{menu.menu.item !== null ? (
(() => {
const home = menu.menu.item;
const n = counts.get(home) ?? 0;
return (
<>
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
);
})()
) : (
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
)}
</ContextMenu>
)}
</div>
);
}
function TrashView({
items,
error,
laneName,
onRestore,
onPurge,
onRefresh,
}: {
items: FsTrashItem[];
error: string | null;
onRefresh: () => void;
laneName: (id: number) => string;
onRestore: (id: string) => void;
onPurge: (id: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<FsTrashItem | null>();
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash"
onContextMenu={(e) => menu.open(e, null)}
>
{error && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
</div>
)}
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
<span className="flex-1">{t("fx.trashHint")}</span>
<button
@@ -1692,6 +2176,7 @@ function TrashView({
key={i.id}
className="border-b border-border/40 hover:bg-surface-2"
data-testid="fx-trash-row"
onContextMenu={(e) => menu.open(e, i)}
>
<td className="truncate px-4 py-1.5">
<span className="flex items-center gap-2" title={i.path}>
@@ -1732,13 +2217,44 @@ function TrashView({
))}
</tbody>
</table>
{!items.length && (
{!items.length && !error && (
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
<Trash2 className="h-14 w-14 opacity-40" />
<p className="text-sm">{t("fx.trashEmpty")}</p>
</div>
)}
</div>
{menu.menu && (
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
{menu.menu.item ? (
(() => {
const i = menu.menu.item;
return (
<>
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
{t("fx.restore")}
</MenuItem>
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
{t("fx.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
{t("fx.purge")}
</MenuItem>
</>
);
})()
) : (
<>
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</div>
);
}
@@ -1728,7 +1728,12 @@ export function LaneChangesPanel({
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
// Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
+13 -5
View File
@@ -9,8 +9,9 @@
* first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude and Codex can resume a session by id —
* any other tool's session offers a new session from the workspace home
* it is live the composer is off. Only Claude, Codex and a catalog tool with
* `resumeById` (omp) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton";
import { useAiTools } from "../../hooks/useAiTools";
/** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation);
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
}
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
// Before the catalog loads: the two hand-written launchers resume by id.
const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({
@@ -65,6 +68,7 @@ export function SessionReplay({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { byId } = useAiTools();
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
@@ -88,9 +92,9 @@ export function SessionReplay({
}, [sessionId]);
const tool = session?.tool || "claude";
const resumable = RESUMABLE.has(tool);
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, tool === "claude");
const send = async () => {
if (!text || busy || uploading || livePane || !resumable) return;
@@ -132,7 +136,11 @@ export function SessionReplay({
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
<ConversationView
sessionId={sessionId}
fill
ended={!livePane && !!session && session.status !== "active"}
/>
</div>
{livePane ? (
<div
@@ -1,7 +1,8 @@
/**
* @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume and Fork for a Claude
* session inside a workspace. Opening a session never resumes it; this is the
* session inside a workspace (Resume only for a catalog tool that resumes by
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
* one place that does, behind a warning, because a resume re-reads the whole
* transcript into context. Fork resumes it into a NEW session id
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate";
import { useAiTools } from "../../hooks/useAiTools";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -54,6 +56,7 @@ export function SessionResumeButton({
const navigate = useNavigate();
const { lanes } = useLanes();
const { canEditLane } = usePermissions();
const { byId } = useAiTools();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false);
@@ -70,12 +73,13 @@ export function SessionResumeButton({
};
}, [session.id]);
// Claude only: a Codex session id is not what
// `codex resume` takes for every kind of thread.
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
// Codex session id is not what `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
// The workspace that owns it, else the one whose folder holds it.
const lane =
tool !== "claude" || !session.cwd
(tool !== "claude" && !catalogResume) || !session.cwd
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
@@ -140,7 +144,7 @@ export function SessionResumeButton({
{t("sessions.resume")}
</button>
)}
{resumable && (
{resumable && tool === "claude" && (
<button
type="button"
onClick={() => setConfirming("fork")}
+28 -16
View File
@@ -11,7 +11,7 @@
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
@@ -20,7 +20,6 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useEffect, useRef, useState } from "react";
@@ -34,6 +33,7 @@ import {
MoreHorizontal,
ArrowUp,
SquareTerminal,
Zap,
} from "lucide-react";
import {
api,
@@ -76,7 +76,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -165,12 +165,14 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
// The account's own default (first-run setup) — never bypass.
// The account's own defaults (first-run setup, Settings → AI tools →
// Session defaults); a pick here is for this session only.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
// Off for every tool but Codex, which starts from the account's default.
const [bypass, setBypass] = useState(false);
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
@@ -254,7 +256,7 @@ function HomeBody({
}
};
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, claude);
const start = async () => {
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
@@ -607,15 +609,25 @@ function HomeBody({
</div>
)}
{canBypass && (
<span className="px-2">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
// A chip like the selects beside it: quiet when off, red when on.
<button
type="button"
aria-pressed={bypass}
data-testid="workspace-home-bypass"
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
onClick={() => setBypass(!bypass)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
bypass
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Zap
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
aria-hidden
/>
</span>
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
</button>
)}
</div>
{danger && (
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
created: true,
path: "/home/an/code/big",
});
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/big.git");
await user.click(screen.getByTestId("add-lane-clone-partial"));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
);
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
@@ -0,0 +1,79 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
detail: null,
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -1,7 +1,10 @@
/**
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
* an extra folder, a host that wants a login opens the login row, and Retry
* re-runs only the failed URLs with that login.
* re-runs only the failed URLs with that login. An SSH link while the
* account has no key is never sent — the user is pointed at Settings → Git —
* and an HTTPS host with no saved login is flagged before Clone. The
* partial-clone box adds `partial: true` to every clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -11,16 +14,20 @@ import userEvent from "@testing-library/user-event";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneFolder: vi.fn() } },
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
}));
import { api, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm } from "../CloneSourcesForm";
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => {
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
beforeEach(() => {
vi.mocked(api.lanes.cloneFolder).mockReset();
// Unknown identity: nothing is checked up front.
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
});
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
const user = userEvent.setup();
@@ -53,9 +60,61 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
await user.click(screen.getByTestId("workspace-clone-partial"));
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
url: "https://h/big.git",
partial: true,
})
);
});
it("tells an SSH link from an HTTPS one", () => {
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
expect(cloneTarget("not a url")).toBeNull();
});
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
const user = userEvent.setup();
vi.mocked(api.gitIdentity.get).mockResolvedValue({
ssh: null,
sshKeys: [],
https: [],
} as unknown as GitIdentity);
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
await user.type(
screen.getByTestId("workspace-clone-input"),
"git@github.com:o/r.git\nhttps://h.test/a.git"
);
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
});
});
@@ -3,7 +3,11 @@
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, and restores from the Trash.
* batch over 500 MB before sending anything, restores from the user's own
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -12,13 +16,18 @@ import userEvent from "@testing-library/user-event";
import { FileExplorer } from "../FileExplorer";
import { api, type FsListing } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
source ? { lane, source } : session ? { lane, session } : lane,
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
api: {
lanes: {
list: vi.fn(),
clone: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -32,9 +41,12 @@ vi.mock("../../../lib/api", () => ({
copy: vi.fn(),
remove: vi.fn(),
trash: vi.fn(),
allTrash: vi.fn(),
restore: vi.fn(),
purge: vi.fn(),
upload: vi.fn(),
uploadBatch: vi.fn(),
uploadCheck: vi.fn(),
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
},
},
@@ -70,6 +82,7 @@ beforeEach(() => {
} as never);
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
vi.mocked(api.files.homes).mockResolvedValue({
homes: [
{ id: "me", label: "An", readOnly: false },
@@ -185,6 +198,41 @@ describe("FileExplorer", () => {
);
});
it("sends many small files as one batch, asking once about every taken name", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Replace" }));
await waitFor(() =>
expect(api.files.uploadBatch).toHaveBeenCalledWith(
1,
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.any(Function)
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
expect(api.files.upload).not.toHaveBeenCalled();
});
it("falls back to one request per file when the server cannot take a batch", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
@@ -212,10 +260,178 @@ describe("FileExplorer", () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "Trash" }));
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
const trashRow = await screen.findByTestId("fx-trash-row");
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
await waitFor(() =>
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
);
});
it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => {
vi.mocked(api.files.allTrash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
home: "bob",
owner: "Bob",
},
],
errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
});
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
},
],
});
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const trashes = screen.getByTestId("fx-trashes");
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
const folders = await screen.findAllByTestId("fx-trash-folder");
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
await user.click(folders[1]!);
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
const trashRow = await screen.findByTestId("fx-trash-row");
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
)
);
expect(api.files.purge).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
await waitFor(() =>
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
);
// Back to the folders through the path bar.
await user.click(
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
name: "Trash",
})
);
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
});
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
vi.mocked(api.files.allTrash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
home: "bob",
owner: "Bob",
},
],
errors: [],
});
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
},
],
});
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
const folders = await screen.findAllByTestId("fx-trash-folder");
// My Trash is empty: its Empty Trash is disabled.
fireEvent.contextMenu(folders[0]!);
expect(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
).toHaveProperty("disabled", true);
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.contextMenu(folders[1]!);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
);
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
);
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
await user.click(
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
);
await waitFor(() =>
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
);
});
it("right-clicks a sidebar workspace and a path crumb", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
);
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
);
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
});
it("shows why a Trash could not be read instead of an empty one", async () => {
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
expect(screen.queryByText("The Trash is empty.")).toBeNull();
});
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
@@ -242,7 +458,7 @@ describe("FileExplorer", () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(await screen.findByRole("button", { name: /Bob/ }));
await user.click(await screen.findByRole("button", { name: "Bob" }));
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
@@ -255,3 +471,87 @@ describe("FileExplorer", () => {
expect(api.files.remove).not.toHaveBeenCalled();
});
});
describe("FileExplorer — New → Clone repository", () => {
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "New" }));
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
return screen.getByTestId("fx-clone-dialog");
};
it("clones into the open folder of the workspace and says where", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
created: true,
path: "/w/one/src/repo",
} as never);
const user = userEvent.setup();
open();
fireEvent.doubleClick(await screen.findByText("src"));
await screen.findByText("x.js");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith({
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
)
.mockResolvedValueOnce({
lane: { id: 9, title: "r", cwd: "/w/one/r" },
created: true,
path: "/w/one/r",
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
const login = await within(dialog).findByTestId("fx-clone-login");
await user.type(within(login).getByLabelText("Username"), "ann");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenLastCalledWith(
expect.objectContaining({
parent: "/w/one",
credentials: { username: "ann", password: "tok", remember: true },
})
)
);
});
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
const dialog = await openClone(user);
const url = within(dialog).getByLabelText("Repository URL");
await user.type(url, "abc{Backspace}{Delete}");
expect(api.files.remove).not.toHaveBeenCalled();
expect(url).toHaveValue("ab");
});
it("is not offered in an extra folder", async () => {
const user = userEvent.setup();
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "New" }));
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
});
});
@@ -1062,6 +1062,20 @@ describe("LaneChangesPanel", () => {
);
});
it("does not offer Undo for a top commit its upstream already holds", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
await waitFor(() => expect(undo).toBeDisabled());
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
});
it("cherry-picks only from another branch's history", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts());
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
return params.get("run") ? null : (
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
);
}
const user = userEvent.setup();
render(
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
import {
DEFAULT_MODES,
readDefaultMode,
readDefaultTool,
SAFE_MODES,
writeDefaultMode,
writeDefaultTool,
type DefaultMode,
@@ -310,7 +310,7 @@ function AgentStep() {
writeDefaultMode(m);
setMode(m);
}}
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
/>
</div>
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
@@ -2,7 +2,7 @@
* @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only
* is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only
* for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
}));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -98,10 +98,12 @@ describe("account preferences", () => {
}
});
it("never keeps bypass as the starting permission mode", () => {
it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto");
writeDefaultMode("bypassPermissions" as never);
expect(readDefaultMode()).toBe("bypassPermissions");
expect(readDefaultMode({ safe: true })).toBe("auto");
expect(SAFE_MODES).not.toContain("bypassPermissions");
writeDefaultMode("nonsense" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan");
+34 -2
View File
@@ -1,7 +1,10 @@
/**
* @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach —
* files (the prompt box's own picker), Choose a file on this computer (a
* browser on the dashboard's own machine only: the OS's own picker,
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
* nothing uploaded), what is already there to attach —
* Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
@@ -21,6 +24,7 @@ import {
ChevronRight,
FileText,
FolderOpen,
HardDrive,
History,
Paperclip,
Plug,
@@ -29,7 +33,14 @@ import {
Settings2,
} from "lucide-react";
import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
import {
isLocalBrowser,
localAttachment,
projectAttachment,
recentUploads,
type Attachment,
} from "../../lib/promptAttach";
import { toast } from "../../lib/toast";
interface Entry {
name: string;
@@ -244,6 +255,17 @@ export function ChatAddMenu({
close();
onAttach([item]);
};
const pickLocal = async () => {
close();
// The dialog opens on the desktop, sometimes behind this window.
toast.info(t("chat.pickLocalOpen"));
try {
const r = await api.lanes.pickPath("file", cwd || undefined);
if (r.path) onAttach([localAttachment(r.path)]);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const manage = (tab: "skills" | "plugins") => {
close();
navigate(`/cc-config?tab=${tab}`);
@@ -291,6 +313,16 @@ export function ChatAddMenu({
onUpload();
}}
/>
{isLocalBrowser() && (
<Row
icon={<HardDrive className="h-4 w-4" />}
label={t("chat.pickLocal")}
title={t("chat.pickLocalHint")}
testId="chat-add-pick-local"
onHover={() => setSub(null)}
onClick={() => void pickLocal()}
/>
)}
<Row
icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")}
@@ -218,6 +218,7 @@ export function LaneConsolePane({
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)}
tool={handle.tool}
// 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)
// rather than leaving a dead terminal the user has to click out of.
+28 -9
View File
@@ -59,7 +59,13 @@ import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import {
ModelMenu,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
@@ -397,7 +403,11 @@ export const PaneChat = memo(function PaneChat({
// status filter ignored: check the status here.
.then((r) => {
const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working"));
// A fresh start, resume or `/clear` leaves main "working" at an
// empty prompt (SessionStart's flag) until the first prompt.
setWorking(
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
);
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
@@ -470,7 +480,10 @@ export const PaneChat = memo(function PaneChat({
// Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openTerminal = useCallback(
() => showTerminal(runId, sessionId, run.tool),
[runId, sessionId, run.tool]
);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
@@ -491,7 +504,7 @@ export const PaneChat = memo(function PaneChat({
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would.
const command = async (line: string) => {
const command = async (line: string, files: string[] = []) => {
if (
notStarted &&
!(await confirmDialog({
@@ -501,14 +514,15 @@ export const PaneChat = memo(function PaneChat({
}))
)
return false;
const ok = submitToRun(runId, line);
const ok = submitToRun(runId, line, files);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = async () => {
if (!text || uploading) return;
if (!(await command(text))) return;
const out = promptForPane(prompt, images, claude);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
@@ -551,7 +565,7 @@ export const PaneChat = memo(function PaneChat({
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="mx-auto w-full max-w-3xl">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
@@ -568,7 +582,7 @@ export const PaneChat = memo(function PaneChat({
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
>
<PromptEditor
bare
@@ -623,8 +637,13 @@ export const PaneChat = memo(function PaneChat({
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
>
{/* A turning ring: the turn is still going. */}
<span
aria-hidden
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
/>
<Square className="h-3 w-3 fill-current" />
</button>
) : (
+5 -5
View File
@@ -73,7 +73,7 @@ import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup";
import { promptForPane } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -244,10 +244,10 @@ const ITEM =
* the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
export function showTerminal(runId: string, sessionId: string, tool?: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
title={label}
aria-label={label}
onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
if (chat) return showTerminal(run.id, sessionId, run.tool);
const draft = readChatDraft(sessionId);
const text = takeRunInput(run.id);
if (text)
+101 -18
View File
@@ -11,7 +11,9 @@
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
* as an `@path` chip — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, never uploaded —
* a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh;
@@ -67,9 +69,14 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
claudeMention,
droppedAttachments,
droppedSkill,
droppedText,
isLocalBrowser,
localAttachment,
localCandidates,
sameFilesHere,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -203,9 +210,37 @@ interface PromptEditorProps {
export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
.filter(Boolean)
.join(" ");
}
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
* `@` mention, and a trailing space so a mention at the end does not leave
* the `@` file suggester open to swallow the Enter after it. Any other tool:
* the text with every non-image file in it, and the images' paths apart,
* each in a paste of its own (how `codex` attaches a pasted image path). */
export function promptForPane(
text: string,
images: PromptImage[],
claude = false
): { text: string; files: string[] } {
if (claude) {
const all = promptWithImages(text, images, true);
return { text: images.length ? `${all} ` : all, files: [] };
}
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(
text,
images.filter((i) => !isImage(i))
),
files: images.filter(isImage).map((i) => i.path),
};
}
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
@@ -373,7 +408,11 @@ export function PromptEditor({
// A pasted or dropped file is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
// its path is added to the prompt only when it is sent (promptWithImages).
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
null
);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
const uploading = upload === "uploading";
@@ -384,20 +423,64 @@ export function PromptEditor({
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
path: saved[i]!.path,
// Each file on its own: one refused file does not cost the others.
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
const added: PromptImage[] = [];
const errors: unknown[] = [];
results.forEach((r, i) => {
if (r.status === "rejected") return void errors.push(r.reason);
const f = picked[i]!;
added.push({
path: r.value.path,
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
});
});
if (added.length) {
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
keepError.current = errors.length > 0; // these came with the error
onImagesChange((prev) => [...prev, ...added]);
})
.catch(() => setUpload("failed"));
}
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
setUpload(
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
);
});
return true;
};
/** Dropped or pasted files: by their own paths when the browser is on the
* dashboard's machine and the server finds the same files there (a file
* manager hands over `file://` paths beside the bytes), else uploaded. */
const attachDropped = (
dt: DataTransfer | null | undefined,
list: FileList | null | undefined
) => {
const files = Array.from(list ?? []);
if (files.length === 0) return false;
const paths = localCandidates(dt, files);
if (!paths) return attachImages(files);
setUpload("uploading");
void sameFilesHere(paths, files).then((same) => {
if (!same) return void attachImages(files);
setUpload(null);
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
});
return true;
};
// Anything attached since — a path, a panel row, a + menu pick — ends a past
// upload's error; not the files that uploaded beside the one refused.
const keepError = useRef(false);
const imageCount = useRef(images.length);
useEffect(() => {
const grew = images.length > imageCount.current;
imageCount.current = images.length;
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
keepError.current = false;
}, [images.length]);
const removeImage = (img: PromptImage) => {
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
@@ -426,7 +509,7 @@ export function PromptEditor({
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
@@ -591,7 +674,7 @@ export function PromptEditor({
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
@@ -635,10 +718,10 @@ export function PromptEditor({
</div>
{upload && (
<p
role={upload === "failed" ? "alert" : "status"}
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
role={upload === "uploading" ? "status" : "alert"}
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
>
{t(`drop.${upload}`)}
{t(`drop.${upload}`, { mb: limitMb })}
</p>
)}
{kind && (
+58 -18
View File
@@ -16,8 +16,10 @@
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them (an image as an image) when the prompt is sent; a browser on the
* dashboard's own machine pastes a file manager's files by their own paths
* instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -63,11 +65,17 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import {
claudeMention,
isLocalBrowser,
localCandidates,
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -160,6 +168,9 @@ interface TerminalViewProps {
* so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean;
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
* as an `@` mention (`claudeMention`), any other one its plain path. */
tool?: string;
}
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
@@ -170,6 +181,7 @@ export const TerminalView = memo(function TerminalView({
onExit,
autoFocus = true,
covered = false,
tool,
}: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
@@ -193,6 +205,8 @@ export const TerminalView = memo(function TerminalView({
const tRef = useRef(t);
tRef.current = t;
const [drop, setDrop] = useState<DropState | null>(null);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
// The socket dropped after it had been up: keystrokes are not reaching tmux.
const [reconnecting, setReconnecting] = useState(false);
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
@@ -207,10 +221,10 @@ export const TerminalView = memo(function TerminalView({
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (state: DropState) => {
const flash = (state: DropState, ms = 2500) => {
setDrop(state);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setDrop(null), 2500);
flashTimer.current = setTimeout(() => setDrop(null), ms);
};
useEffect(
() => () => {
@@ -235,7 +249,8 @@ export const TerminalView = memo(function TerminalView({
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
void attachFiles(Array.from(e.dataTransfer.files));
const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -250,23 +265,42 @@ export const TerminalView = memo(function TerminalView({
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
/** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths
* the uploads were saved at. A refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null = null) => {
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
let paths = local && (await sameFilesHere(local, files)) ? local : null;
const errors: unknown[] = [];
if (!paths) {
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
paths = results.flatMap((r) => {
if (r.status === "fulfilled") return [r.value.path];
errors.push(r.reason);
return [];
});
}
if (paths.length) {
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
const asText = tool === "claude" ? claudeMention : (p: string) => p;
termRef.current?.paste(paths.map(asText).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
flash("failed");
}
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
if (!errors.length) setDrop(null);
else if (!tooBig) flash("failed");
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
};
useEffect(() => {
@@ -666,8 +700,12 @@ export const TerminalView = memo(function TerminalView({
() =>
registerPane(
runId,
(text, submit) => {
termRef.current?.paste(text);
(text, submit, files = []) => {
const term = termRef.current;
// The text, then each image's path in a paste of its own — the one
// shape `claude` turns into `[Image #n]` on every platform.
if (text) term?.paste(files.length ? `${text} ` : text);
files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f));
// 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.
@@ -868,10 +906,12 @@ export const TerminalView = memo(function TerminalView({
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
{t(`drop.${drop}`, { mb: limitMb })}
</div>
)}
</div>
@@ -2,13 +2,14 @@
* @file ChatAddMenu.test.tsx
* @description The `+` menu's existing-resource pickers: Project files (a
* search with folders, attached as `@path`), the workspace's Context files
* (files only, none missing) and this browser's Recent uploads.
* (files only, none missing), this browser's Recent uploads and, for a
* browser on the dashboard's machine, a file chosen in the OS's own picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ChatAddMenu } from "../ChatAddMenu";
import { api } from "../../../lib/api";
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
api: {
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
lanes: {
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
sources: vi.fn().mockResolvedValue({
sources: [
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
@@ -61,6 +63,28 @@ beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => vi.unstubAllGlobals());
describe("ChatAddMenu — a file on this computer", () => {
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
const onAttach = renderMenu();
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
await vi.waitFor(() =>
expect(onAttach).toHaveBeenCalledWith([
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
])
);
});
it("is not offered to a browser on another machine", () => {
// Only a loopback address: a name that merely starts with "localhost" is not one.
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
renderMenu();
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
});
});
describe("ChatAddMenu — existing resources", () => {
it("searches the project, folders included, and attaches a pick as @path", async () => {
const onAttach = renderMenu();
@@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => {
renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []);
});
it("shows the mode the pane shows, and asks the server to step it", async () => {
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
@@ -462,7 +462,10 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
// A trailing space: an @ mention at the end would leave claude's file
// suggester open, and it takes the Enter.
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
[]
);
});
@@ -490,7 +493,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", []));
});
it("sends without asking once the session has started", async () => {
@@ -499,7 +502,7 @@ describe("PaneChat — the unsent prompt", () => {
renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", []));
expect(confirmDialog).not.toHaveBeenCalled();
});
});
@@ -24,6 +24,7 @@ import {
ModelPicker,
PASTE_AS_FILE_CHARS,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "../RunSetup";
@@ -55,7 +56,11 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
run: {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
localPaths: r({ sizes: [1] }),
},
ccConfig: {
commands: r({
items: [
@@ -341,6 +346,120 @@ describe("PromptEditor — newlines and images", () => {
});
});
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
fireEvent.drop(box(), {
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
});
expect(await screen.findByText("demo.mp4")).toBeTruthy();
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
});
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
fireEvent.drop(box(), {
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
});
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
});
it("keeps the files that uploaded when another one is refused", async () => {
vi.mocked(api.run.dropFile)
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
fireEvent.drop(box(), {
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
});
expect(await screen.findByText("ok.txt")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
// Anything attached afterwards — here a Files panel row — ends the error.
const data: Record<string, string> = {
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
};
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
expect(await screen.findByText("a.ts")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
it("says a file over the server's limit is too big, and how to attach it", async () => {
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
);
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={() => {}}
/>
);
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
expect((await screen.findByRole("alert")).textContent).toMatch(
/too large to upload \(max 20 MB\).*Choose a file on this computer/
);
});
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
@@ -586,3 +705,51 @@ describe("ModelMenu", () => {
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
});
});
describe("promptForPane — images apart, every other file in the text", () => {
it("keeps @-mentions and other files in the text, and takes images out", () => {
// A Windows path inside a paste of text stayed a plain path in claude; a
// paste that is only the path becomes [Image #n].
const images: PromptImage[] = [
{
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
name: "image.png",
},
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
];
expect(promptForPane(" look ", images)).toEqual({
text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"',
files: [
"C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
"/data/drops/0123456789abcdef.JPG",
],
});
});
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
// A bare pasted path stayed plain text on Windows, and a path in the
// command-line prompt is never an image; claude reads an @ mention at submit.
const images: PromptImage[] = [
{
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
name: "image.png",
},
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
];
expect(promptForPane(" look ", images, true)).toEqual({
text:
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
files: [],
});
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
expect(promptWithImages("see", [images[1]!], true)).toBe(
"see @/data/drops/0123456789abcdef-log.txt"
);
});
});
@@ -26,8 +26,14 @@ const inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
const localPathsMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths),
},
},
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -512,6 +518,23 @@ describe("TerminalView", () => {
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("pastes a local file manager's own paths, uploading nothing", async () => {
dropFileMock.mockReset();
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = {
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
};
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
expect(dropFileMock).not.toHaveBeenCalled();
});
it("pastes every path of several dropped files, images or not", async () => {
dropFileMock
.mockReset()
@@ -9,17 +9,24 @@
* 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). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js).
* the machine, database included (scripts/uninstall.js). Quit is a plain
* button and Uninstall a danger one, both the same width with an icon, and
* each spins while its request runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Power, Trash2 } from "lucide-react";
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
// The two action buttons share one width, so they line up under the switches.
const ACTION = "min-w-[6.5rem] justify-center text-xs";
const ICON = "h-3.5 w-3.5 shrink-0";
export function AutostartSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<AutostartStatus | null>(null);
@@ -126,9 +133,11 @@ export function AutostartSettings() {
type="button"
data-testid="quit-agenthub"
disabled={quitting}
aria-busy={quitting}
onClick={() => void quit()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
className={`btn-secondary ${ACTION}`}
>
{quitting ? <Loader2 className={ICON + " animate-spin"} /> : <Power className={ICON} />}
{t("quit.button")}
</button>
}
@@ -141,9 +150,15 @@ export function AutostartSettings() {
type="button"
data-testid="uninstall-agenthub"
disabled={uninstalling}
aria-busy={uninstalling}
onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
className={`btn-danger ${ACTION}`}
>
{uninstalling ? (
<Loader2 className={ICON + " animate-spin"} />
) : (
<Trash2 className={ICON} />
)}
{t("uninstall.button")}
</button>
}
@@ -2,12 +2,19 @@
* @file GitIdentitySettings.tsx
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
* commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, since a member's commits fail without
* one), the SSH
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
* since a member's commits fail without one; a new SSH key is labelled with
* that email), the SSH
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
* it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82) — and, for
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
* each tied to the one key it uses (the SSH part is split into tabs: one per
* kind of host, the extra keys, the other hosts) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
@@ -15,14 +22,25 @@
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
Cloud,
Copy,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate";
import { PageTabs } from "../PageTabs";
import { Switch } from "../Switch";
const INPUT =
@@ -30,6 +48,140 @@ const INPUT =
const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
// The regions AWS CodeCommit runs in.
const AWS_REGIONS = [
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"sa-east-1",
"me-south-1",
"af-south-1",
"il-central-1",
];
function Step({ n, children }: { n: number; children: ReactNode }) {
return (
<div className="flex gap-2 text-[11px] text-fg-secondary">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
{n}
</span>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
function Code({ children }: { children: ReactNode }) {
return (
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
{children}
</code>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
*/
function KeyCard({
title,
sub,
type,
k,
id,
busy,
onGenerate,
onRemove,
children,
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
k: Key | undefined;
id: GitIdentity;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
return (
<section aria-label={title} className="space-y-2 rounded border border-border p-2.5">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
{k ? (
<>
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
</Step>
{children}
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -40,6 +192,8 @@ export function GitIdentitySettings() {
const me = auth.mode === "server" ? auth.user : null;
// null = not edited: the field shows the saved value, else the account's.
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
// null = not picked: opens on the first kind of host that has a key.
const [sshTab, setSshTab] = useState<string | null>(null);
const load = useCallback(async () => {
try {
@@ -69,23 +223,50 @@ export function GitIdentitySettings() {
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// An account may hold one of each; an older server answers `ssh` only.
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
const typeTaken = keys.some((k) => (k.type ?? "ed25519") === keyType);
const generate = async () => {
// The default key of a type (a named key sits beside it).
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
};
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
const makeNamed = async () => {
if (!newKey) return;
const ok = await run(
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
const removeNamed = async (keyId: string) => {
if (
typeTaken &&
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKeyById(keyId));
};
const generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type);
if (
taken &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(() => api.gitIdentity.generateKey(typeTaken, keyType), t("gitIdentity.keyMade"));
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
};
const removeKey = async (type?: "ed25519" | "rsa") => {
const removeKey = async (type: "ed25519" | "rsa") => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
@@ -113,6 +294,76 @@ export function GitIdentitySettings() {
if (ok) setForm(null);
};
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
// one host per region) gets its own form under the RSA key; anything else
// is the "other hosts" form.
const sshUsers = id?.sshUsers ?? [];
const awsRows = sshUsers.flatMap((r) => {
const m = CODECOMMIT_RE.exec(r.host);
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
if (m?.[1]) host = m[1];
const alias = other.alias.trim().toLowerCase();
const ok = await run(
() =>
api.gitIdentity.setSshUser({
host,
user: other.user.trim(),
...(alias ? { alias } : {}),
...(other.key ? { key: other.key } : {}),
}),
t("gitIdentity.otherSaved", { host: alias || host })
);
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
};
/** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="select !py-1 !text-xs"
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id && (!only || k.type === only))
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
);
const removeLogin = async (host: string) => {
if (
!(await confirmDialog({
@@ -134,13 +385,38 @@ export function GitIdentitySettings() {
const member = id.account === "member";
const authorShown = author ?? {
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
email: id.author.email ?? me?.email ?? "",
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
};
const authorUnset = !id.author.name || !id.author.email;
const authorDirty =
authorUnset ||
authorShown.name.trim() !== (id.author.name ?? "") ||
authorShown.email.trim() !== (id.author.email ?? "");
// The SSH keys, one tab per kind of host — every section at once was a wall.
const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
...(named.length > 0 || id.writable
? [
{
id: "more",
label: named.length
? `${t("gitIdentity.tabMore")} (${named.length})`
: t("gitIdentity.tabMore"),
Icon: KeySquare,
},
]
: []),
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []),
];
const activeSshTab =
sshTab && sshTabs.some((x) => x.id === sshTab)
? sshTab
: !keyOf("ed25519") && keyOf("rsa")
? "aws"
: "git";
const saveAuthor = async () => {
const ok = await run(
() =>
@@ -206,89 +482,343 @@ export function GitIdentitySettings() {
)}
</form>
<div className="space-y-2 rounded-lg border border-border p-3">
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
{id.writable && (
<select
aria-label={t("gitIdentity.keyType")}
value={keyType}
disabled={busy}
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
className="select !py-1 !text-xs"
>
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
</select>
)}
{id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
{typeTaken ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{typeTaken ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
</div>
{keys.length ? (
<>
{keys.map((k) => (
<div key={k.type ?? "ed25519"} className="space-y-1.5">
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={k.type === "rsa" ? 4 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.type === "rsa" ? "RSA 4096 · " : "ED25519 · "}
{k.fingerprint}
</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
<PageTabs
ariaLabel={t("gitIdentity.sshTitle")}
tabs={sshTabs}
activeId={activeSshTab}
onSelect={setSshTab}
>
{activeSshTab === "git" && (
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
)}
{activeSshTab === "aws" && (
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `${r.alias} · ` : ""}
{r.region} · {r.user} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={aws.alias}
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
)}
</KeyCard>
)}
{activeSshTab === "more" && (
<section
aria-label={t("gitIdentity.moreKeysTitle")}
className="space-y-2 rounded border border-border p-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.moreKeysTitle")}
</div>
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
</div>
{id.writable && !newKey && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setNewKey({ name: "", type: "rsa" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.newKey")}
</button>
{id.writable && (
)}
</div>
{newKey && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
onSubmit={(e) => {
e.preventDefault();
void makeNamed();
}}
>
<input
aria-label={t("gitIdentity.keyName")}
placeholder={t("gitIdentity.keyNamePlaceholder")}
autoComplete="off"
autoFocus
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.keyType")}
value={newKey.type}
onChange={(e) =>
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
}
className="select !py-1 !text-xs"
>
<option value="rsa">RSA 4096 (AWS)</option>
<option value="ed25519">ED25519</option>
</select>
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
{t("gitIdentity.generate")}
</button>
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
{t("gitCreds.cancel")}
</button>
</form>
)}
{named.map((k) => (
<div
key={k.id}
className="space-y-1 rounded border border-border-light p-2"
data-testid="git-named-key"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-medium text-fg-primary">
{k.name}
</span>
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{k.type === "rsa" ? "RSA" : "ED25519"}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
</span>
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void removeKey(k.type)}
className={BTN}
onClick={() => void copyText(k.publicKey)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
)}
</div>
</div>
))}
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
onClick={() => void removeNamed(k.id!)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
value={k.publicKey}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
/>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
))}
</section>
)}
{activeSshTab === "other" && (
<section
aria-label={t("gitIdentity.otherTitle")}
className="space-y-1.5 rounded border border-border p-2.5"
>
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.otherTitle")}
</div>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
<div className="space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => (
<div key={r.alias ?? r.host} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `ssh://${r.alias}/ → ` : ""}
{r.user}@{r.host} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveOther();
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="git.example.com"
value={other.host}
onChange={(e) => setOther({ ...other, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.otherUser")}
placeholder={t("gitIdentity.otherUser")}
autoComplete="off"
value={other.user}
onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={other.alias}
onChange={(e) => setOther({ ...other, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(other.key, (key) => setOther({ ...other, key }))}
<button
type="submit"
className={BTN}
disabled={busy || !other.host.trim() || !other.user.trim()}
>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</div>
</section>
)}
</PageTabs>
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
</div>
{member && (
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
@@ -0,0 +1,180 @@
/**
* @file RetentionSettings.tsx
* @description Settings → Data → Session cleanup, under the manual purge: the
* OPT-IN automatic cleanup (`AGENTHUB_RETENTION_DAYS`, 0 = off, the default)
* that the hourly maintenance applies, the database's size and free space, and
* **Compact now** — a full VACUUM behind a confirm, polled through
* `GET /api/settings/retention` while it runs, then toasted. Backed by
* `server/lib/db-retention.js`; a value the environment set is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Minimize2, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import type { RetentionState } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
export function RetentionSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<RetentionState | null>(null);
const [days, setDays] = useState("0");
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.retention
.get()
.then((s) => {
setState(s);
setDays(String(s.days));
})
.catch(() => undefined);
}, []);
// A Compact takes a while on a large database: poll until it ends.
const compacting = !!state?.compacting;
useEffect(() => {
if (!compacting) return;
const id = window.setInterval(() => {
api.settings.retention
.get()
.then((s) => {
setState(s); // the draft keeps its value
if (s.compacting || !s.last_compact) return;
if (s.last_compact.ok) {
toast.success(
t("retention.compactDone", {
before: formatBytes(s.last_compact.size_before ?? 0),
after: formatBytes(s.last_compact.size_after ?? 0),
})
);
} else {
toast.error(t("retention.compactFailed", { error: s.last_compact.error }));
}
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [compacting, t]);
const max = state?.max_days ?? 3650;
const n = Number(days);
const valid = days.trim() !== "" && Number.isInteger(n) && n >= 0 && n <= max;
const changed = state !== null && valid && n !== state.days;
const owned = !!state?.env_owned.days;
async function save() {
setSaving(true);
try {
const s = await api.settings.retention.save(n);
setState(s);
setDays(String(s.days));
toast.success(t("retention.saved"));
} catch (err) {
toast.error(err);
} finally {
setSaving(false);
}
}
async function compact() {
const ok = await confirmDialog({
title: t("retention.compactConfirmTitle"),
message: t("retention.compactConfirmBody"),
confirmLabel: t("retention.compact"),
});
if (!ok) return;
try {
setState(await api.settings.retention.compact());
} catch (err) {
toast.error(err);
}
}
const purged = state?.last_run?.retention?.purged_sessions;
return (
<div className="border-t border-border pt-4 space-y-3">
<div>
<h4 className="text-xs font-medium text-fg-secondary">{t("retention.title")}</h4>
<p className="text-xs text-fg-muted">{t("retention.description")}</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label htmlFor="retention-days" className="text-xs text-fg-secondary block mb-2">
{t("retention.days")}
</label>
<div className="flex items-center gap-2">
<input
id="retention-days"
type="number"
min="0"
max={max}
step="1"
inputMode="numeric"
value={days}
disabled={!state || owned}
onChange={(e) => setDays(e.target.value)}
aria-invalid={!valid || undefined}
aria-describedby={!valid ? "retention-days-error" : undefined}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:days")}</span>
</div>
{!valid && (
<p id="retention-days-error" className="mt-1 text-[11px] text-status-danger">
{t("retention.invalid", { max })}
</p>
)}
</div>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || owned}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
<p className="text-xs text-fg-secondary">
{state && state.days > 0 ? t("retention.on", { days: state.days }) : t("retention.off")}
{state?.last_run && purged !== undefined && (
<> {t("retention.lastRun", { time: formatDateTime(state.last_run.at), n: purged })}</>
)}
</p>
{owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void compact()}
disabled={!state || compacting}
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
>
{compacting ? (
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
) : (
<Minimize2 className="w-3 h-3 inline mr-1" />
)}
{compacting ? t("retention.compacting") : t("retention.compact")}
</button>
{state?.space && (
<span className="text-xs text-fg-muted">
{t("retention.space", {
size: formatBytes(state.db_size),
free: formatBytes(state.space.free_bytes),
mode: state.space.auto_vacuum,
})}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,154 @@
/**
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
* account's own starting choices for a new Claude Code session (permission
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
* synced per account). Turning bypass on as a default asks first; a pick in
* the prompt box still changes only that one session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert } from "lucide-react";
import type { CodexApproval, CodexSandbox } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { confirmDialog } from "../../lib/confirm";
import {
CODEX_APPROVALS,
CODEX_SANDBOXES,
DEFAULT_MODES,
readCodexDefaults,
readDefaultMode,
writeCodexDefaults,
writeDefaultMode,
type DefaultMode,
} from "../../lib/sessionDefaults";
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
const MODE_KEYS: Record<DefaultMode, string> = {
auto: "Auto",
acceptEdits: "AcceptEdits",
default: "Default",
plan: "Plan",
bypassPermissions: "Bypass",
};
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
"read-only": "sandboxReadOnly",
"workspace-write": "sandboxWorkspaceWrite",
"danger-full-access": "sandboxFullAccess",
};
const APPROVAL_KEYS: Record<CodexApproval, string> = {
"on-request": "approvalOnRequest",
never: "approvalNever",
};
function Danger({ children }: { children: string }) {
return (
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{children}
</p>
);
}
export function SessionDefaultsSettings() {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
const [codex, setCodex] = useState(readCodexDefaults);
const askBypass = (tool: string) =>
confirmDialog({
title:
tool === "Codex"
? t("sessionDefaults.codexConfirmTitle")
: t("sessionDefaults.confirmTitle", { tool }),
message: t("sessionDefaults.confirmBody", { tool }),
confirmLabel: t("sessionDefaults.confirmLabel"),
});
const pickMode = async (m: DefaultMode) => {
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
writeDefaultMode(m);
setMode(m);
};
const patchCodex = (patch: Partial<typeof codex>) => {
writeCodexDefaults(patch);
setCodex((c) => ({ ...c, ...patch }));
};
const pickCodexBypass = async (on: boolean) => {
if (on && !(await askBypass("Codex"))) return;
patchCodex({ bypass: on });
};
if (!shown("claude") && !shown("codex")) return null;
return (
<SettingsSection
id="session-defaults"
title={t("sessionDefaults.title")}
description={t("sessionDefaults.description")}
>
{shown("claude") && (
<>
<SettingsRow
label={t("sessionDefaults.claudeMode")}
description={t("sessionDefaults.claudeModeDesc")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.claudeMode")}
value={mode}
onChange={(m) => void pickMode(m)}
options={DEFAULT_MODES.map((m) => ({
value: m,
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
}))}
/>
}
/>
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
</>
)}
{shown("codex") && (
<>
<SettingsRow
label={t("sessionDefaults.codexSandbox")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexSandbox")}
value={codex.sandbox}
onChange={(sandbox) => patchCodex({ sandbox })}
options={CODEX_SANDBOXES.map((v) => ({
value: v,
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
}))}
/>
}
/>
<SettingsRow
label={t("sessionDefaults.codexApproval")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexApproval")}
value={codex.approval}
onChange={(approval) => patchCodex({ approval })}
options={CODEX_APPROVALS.map((v) => ({
value: v,
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
}))}
/>
}
/>
<SettingsSwitchRow
label={t("sessionDefaults.codexBypass")}
description={t("sessionDefaults.codexBypassDesc")}
checked={codex.bypass}
onChange={(on) => void pickCodexBypass(on)}
/>
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
</>
)}
</SettingsSection>
);
}
+31 -4
View File
@@ -4,7 +4,8 @@
* switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* themselves with a hairline, none after the last, so a section is just its
* rows in order.
* rows in order. A string description shows its `backticked` words as code,
* and a `disabled` row dims its label, so an unavailable control reads as one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -32,30 +33,55 @@ export function SettingsSection({
);
}
/** `text` with every `backticked` run drawn as inline code (a command to type). */
export function withCode(text: string): ReactNode {
const parts = text.split(/`([^`]+)`/);
if (parts.length === 1) return text;
return parts.map((p, i) =>
i % 2 ? (
<code
key={i}
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
>
{p}
</code>
) : (
p
)
);
}
export function SettingsRow({
label,
description,
control,
alignTop = false,
disabled = false,
id,
}: {
label: ReactNode;
description?: ReactNode;
control: ReactNode;
alignTop?: boolean;
/** The control cannot be used here: the label is dimmed with it. */
disabled?: boolean;
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
id?: string;
}) {
return (
<div
id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`}
>
<div className="min-w-0">
<p className="text-sm text-fg-secondary">{label}</p>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
{description && (
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
{typeof description === "string" ? withCode(description) : description}
</p>
)}
</div>
<div className="shrink-0">{control}</div>
</div>
@@ -82,6 +108,7 @@ export function SettingsSwitchRow({
id={id}
label={label}
description={description}
disabled={disabled}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
/>
);
@@ -2,11 +2,16 @@
* @file Tests for Settings → Git → Your git identity: a user with no key
* makes one, a key shows only its public half and copies it, replacing asks
* first, and a member (S3) adds an HTTPS login of their own while the host's
* view leaves HTTPS to the shared Git credentials table.
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; a named key
* is made beside the default ones, and a second AWS account is saved under
* its own name with the one key it uses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitIdentitySettings } from "../GitIdentitySettings";
import { api, type GitIdentity } from "../../../lib/api";
@@ -19,7 +24,10 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(),
generateKey: vi.fn(),
removeKey: vi.fn(),
removeKeyById: vi.fn(),
setTerminalSsh: vi.fn(),
setSshUser: vi.fn(),
removeSshUser: vi.fn(),
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
@@ -40,6 +48,10 @@ const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
...over,
});
/** Opens one tab of the SSH keys. */
const tab = async (name: string | RegExp) =>
userEvent.click(await screen.findByRole("tab", { name }));
beforeEach(() => {
vi.clearAllMocks();
});
@@ -49,7 +61,8 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
const gh = await screen.findByRole("region", { name: /GitHub/ });
await userEvent.click(within(gh).getByRole("button", { name: "Generate key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
@@ -86,43 +99,88 @@ describe("GitIdentitySettings", () => {
expect(await screen.findByText("github.com")).toBeInTheDocument();
});
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", async () => {
it("makes the RSA key from the AWS CodeCommit section", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
);
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
await tab("AWS CodeCommit");
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
expect(await screen.findByLabelText("Public key RSA")).toBeInTheDocument();
});
it("adds an RSA key beside the ED25519 one and deletes each on its own", async () => {
it("keeps each key in its own section and deletes each on its own", async () => {
const RSA = {
type: "rsa" as const,
publicKey: "ssh-rsa AAAAB3 an@agenthub",
fingerprint: "SHA256:r",
};
const ED = { ...KEY, type: "ed25519" as const };
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
);
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
// No RSA key yet: a plain Generate, no replace prompt, the ED25519 key stays.
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
expect(await screen.findByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
expect(screen.getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
const gh = await screen.findByRole("region", { name: /GitHub/ });
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
// One tab at a time: the other host's section is not on screen.
expect(screen.queryByRole("region", { name: "AWS CodeCommit" })).toBeNull();
expect(within(gh).queryByText(/IAM/)).toBeNull();
await tab("AWS CodeCommit");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(screen.queryByRole("region", { name: /GitHub/ })).toBeNull();
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
expect(within(aws).queryByText(/GitHub/)).toBeNull();
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
});
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
const H = "git-codecommit.eu-west-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
);
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
});
it("saves a user for any other SSH host under the advanced section", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY, sshUsers: [] }));
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
);
render(<GitIdentitySettings />);
await tab("Other hosts");
await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x");
await userEvent.type(screen.getByLabelText("User name"), "deploy");
const form = screen.getByLabelText("Host").closest("form")!;
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "git.example.com",
user: "deploy",
});
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
@@ -148,4 +206,69 @@ describe("GitIdentitySettings", () => {
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
});
it("makes a named key and saves a second AWS account under its name with that key", async () => {
const RSA = {
id: "id_rsa",
type: "rsa" as const,
name: null,
publicKey: "ssh-rsa A x",
fingerprint: "SHA256:r",
};
const PROD = {
id: "id_rsa_aws-prod",
type: "rsa" as const,
name: "aws-prod",
publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p",
};
const H = "git-codecommit.ap-southeast-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [RSA, PROD], sshUsers: [] })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
// Only an RSA key: the AWS tab opens first.
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
await tab("More keys");
let more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
await tab("AWS CodeCommit");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA2",
alias: "aws-prod",
key: "id_rsa_aws-prod",
});
expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
await tab("More keys (1)");
more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
);
});
});
@@ -0,0 +1,72 @@
/**
* @file SessionDefaultsSettings.test.tsx
* @description Session defaults: Bypass becomes Claude's default permission
* mode or Codex's default bypass only after the confirm, a cancelled confirm
* keeps the old default, the plain modes save at once, and the pet chat's
* safe read never returns bypass.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
import { confirmDialog } from "../../../lib/confirm";
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
const aiTools = vi.hoisted(() => ({
tools: [
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
{ id: "codex", label: "Codex", installed: true, enabled: true },
],
}));
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
const confirm = vi.mocked(confirmDialog);
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
describe("SessionDefaultsSettings", () => {
beforeEach(() => {
localStorage.clear();
confirm.mockReset();
});
it("saves a plain mode without asking", () => {
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/plan/i));
expect(confirm).not.toHaveBeenCalled();
expect(readDefaultMode()).toBe("plan");
});
it("makes Bypass Claude's default only once confirmed", async () => {
confirm.mockResolvedValueOnce(false);
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
expect(readDefaultMode()).toBe("auto");
confirm.mockResolvedValueOnce(true);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
expect(readDefaultMode({ safe: true })).toBe("auto");
});
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
confirm.mockResolvedValueOnce(true);
render(<SessionDefaultsSettings />);
const sw = screen.getByRole("switch");
fireEvent.click(sw);
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
fireEvent.click(screen.getByRole("switch"));
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
expect(confirm).toHaveBeenCalledTimes(1);
});
it("shows nothing when neither Claude nor Codex is enabled", () => {
const saved = aiTools.tools;
aiTools.tools = [];
const { container } = render(<SessionDefaultsSettings />);
expect(container.firstChild).toBeNull();
aiTools.tools = saved;
});
});
+183 -4
View File
@@ -8,26 +8,31 @@
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
* API key or a pasted `auth.json` — or removes one, each time with their own
* password again; an analyst only reads. The secret only goes IN: the server
* never sends it back.
* never sends it back. An account's Edit (pencil) picks the users who run on
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
* a Claude login goes into their home at once — no restart.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
api,
type AccountPin,
type AccountPool,
type PoolAccountInput,
type PoolProfile,
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
type Row = {
tool: PoolTool;
@@ -59,6 +64,8 @@ function rowsOf(pool: AccountPool): Row[] {
];
}
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
@@ -72,6 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Row | null>(null);
const [editing, setEditing] = useState<Row | null>(null);
const load = useCallback(
(force = false) =>
@@ -148,6 +156,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<div className="text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
<div className="text-[11px] text-fg-secondary">
{t("pool.pinned", {
users: pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username)
.join(", "),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{t(`pool.kind.${r.kind}`)}
@@ -188,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</div>
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<td className="px-5 py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => setEditing(r)}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setRemoving(r)}
@@ -206,7 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</table>
</div>
)}
{adding && (
<AddAccountDialog
onClose={() => setAdding(false)}
@@ -228,10 +254,163 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
}}
/>
)}
{editing && pool && (
<EditAccountDialog
pool={pool}
row={editing}
onClose={() => setEditing(null)}
onDone={(r) => {
const left = r.added.filter(
(u) => u.switched === "own-login" || u.switched === "expired"
);
toast.success(t("pool.editDone", { name: editing.name }));
for (const u of left) {
toast.info(
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
);
}
setEditing(null);
void load();
}}
/>
)}
</section>
);
}
/**
* One account's Edit: tick the users whose sessions run on it. A user already
* on another account of the tool moves here when ticked; unticking one sends
* them back to the least-used account. Saved in one request, password again.
*/
function EditAccountDialog({
pool,
row,
onClose,
onDone,
}: {
pool: AccountPool;
row: Row;
onClose: () => void;
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
}) {
const { t } = useTranslation("users");
const pins = pinsOf(pool, row.tool);
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const toggle = (id: number, on: boolean) =>
setChosen((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.edit", { name: row.name })}
message={t("pool.editHint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return (
<div key={u.id} className="px-3 py-2">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => toggle(u.id, on)}
label={
<span>
{u.username}
{u.disabled && (
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
)}
{elsewhere && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.onAccount", { account: elsewhere.account })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
type ClaudeSource = "profile" | "credentials" | "token";
function AddAccountDialog({
@@ -0,0 +1,267 @@
/**
* @file MemberTmuxCard.tsx
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
* beside the installed one, its sessions, whether an agent of theirs is
* working — so an admin sees who still runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const [data, setData] = useState<MemberTmux | null>(null);
const [error, setError] = useState<string | null>(null);
// A member row = restart that one; "idle" = every idle outdated member.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const load = useCallback(
() =>
api.users
.tmux()
.then((r) => {
setData(r);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
}, [load]);
if (!data?.available) return null;
const rows = data.users.filter((u) => u.running);
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<section className="card" data-testid="member-tmux">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<TerminalSquare className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
<p className="text-xs text-fg-muted">
{t("tmux.description", { version: data.installed ?? "?" })}
</p>
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
title={t("tmux.refresh")}
aria-label={t("tmux.refresh")}
className="btn-icon"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && idleOutdated.length > 0 && (
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
<RotateCcw className="w-4 h-4" />{" "}
{t("tmux.restartIdle", { count: idleOutdated.length })}
</button>
)}
</div>
{!data.matched && (
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
{t("tmux.notMatched")}
</p>
)}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{rows.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("tmux.user")}</th>
<th className={th}>{t("tmux.version")}</th>
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
<th className={th}>{t("tmux.agents")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((u) => (
<tr key={u.id}>
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
{u.startedAt && (
<div className="text-[11px] text-fg-muted">
{t("tmux.since", {
at: formatDateTime(new Date(u.startedAt).toISOString()),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions ?? "—"}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<button
type="button"
onClick={() => setRestarting(u)}
title={t("tmux.restart", { name: u.username })}
aria-label={t("tmux.restart", { name: u.username })}
className="btn-icon"
>
<RotateCcw className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{restarting && (
<RestartDialog
target={restarting}
idle={idleOutdated}
onClose={() => setRestarting(null)}
onDone={(msg) => {
toast.success(msg);
setRestarting(null);
void load();
}}
/>
)}
</section>
);
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
@@ -5,7 +5,8 @@
* admin adds an account only with their own password typed (the token goes
* in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst
* sees no add or remove control.
* sees no add or remove control. An account's Edit ticks the users who run on
* it, saved with the admin's own password; the users show on the account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
profiles: vi.fn(),
setUsers: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { accountPool } };
return { ...actual, api: { accountPool, users } };
});
import { AccountPoolCard } from "../AccountPoolCard";
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
{ id: 7, username: "mia", disabled: false },
{ id: 8, username: "leo", disabled: false },
],
});
accountPool.profiles.mockResolvedValue({
profiles: [
{
@@ -158,4 +167,43 @@ describe("AccountPoolCard", () => {
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
);
});
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
accountPool.list.mockResolvedValue({
...POOL,
pins: {
claude: [
{ userId: 8, username: "leo", account: "team-1" },
{ userId: 7, username: "mia", account: "team-0" },
],
codex: [],
},
});
accountPool.setUsers.mockResolvedValue({
added: [{ userId: 7, username: "mia", switched: "switched" }],
removed: [{ userId: 8, username: "leo" }],
});
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
const dialog = await screen.findByRole("dialog");
const leo = await screen.findByRole("checkbox", { name: /leo/ });
const mia = screen.getByRole("checkbox", { name: /mia/ });
expect(leo).toHaveAttribute("aria-checked", "true");
expect(mia).toHaveAttribute("aria-checked", "false");
expect(screen.getByText("now on team-0")).toBeInTheDocument();
await user.click(mia);
await user.click(leo);
const save = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Save"
)!;
expect(save).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(save);
await waitFor(() =>
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
);
});
});
@@ -0,0 +1,100 @@
/**
* @file MemberTmuxCard.test.tsx
* @description The Users page's members' tmux card: hidden without one Linux
* user per account, lists each running member server with its version and a
* badge when it is older than the installed tmux, warns until tmux-member is
* installed, and an admin restarts one member — or every idle outdated one,
* the working member left out — only with their own password typed; an
* analyst sees no restart control.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { MemberTmux } from "../../../lib/api";
const users = vi.hoisted(() => ({
tmux: vi.fn(),
tmuxRestart: vi.fn(),
tmuxRestartIdle: vi.fn(),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { users } };
});
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { MemberTmuxCard } from "../MemberTmuxCard";
const DATA: MemberTmux = {
available: true,
installed: "3.8",
matched: true,
users: [
{ id: 2, username: "mia", running: true, version: "3.6", sessions: 2, busy: 0, outdated: true },
{ id: 3, username: "zed", running: true, version: "3.6", sessions: 1, busy: 1, outdated: true },
{
id: 4,
username: "ann",
running: true,
version: "3.8",
sessions: 3,
busy: 0,
outdated: false,
},
{ id: 5, username: "bob", running: false, busy: 0, outdated: false },
],
};
beforeEach(() => {
vi.clearAllMocks();
users.tmux.mockResolvedValue(DATA);
});
describe("MemberTmuxCard", () => {
it("renders nothing without one Linux user per account", async () => {
users.tmux.mockResolvedValue({ available: false, users: [] });
const { container } = render(<MemberTmuxCard isAdmin />);
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("lists running servers and marks the ones older than the installed tmux", async () => {
render(<MemberTmuxCard isAdmin={false} />);
expect(await screen.findByText("mia")).toBeInTheDocument();
expect(screen.queryByText("bob")).toBeNull();
expect(screen.getAllByText("older than installed")).toHaveLength(2);
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
// An analyst reads only.
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
});
it("warns until tmux-member is installed", async () => {
users.tmux.mockResolvedValue({ ...DATA, matched: false });
render(<MemberTmuxCard isAdmin />);
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
});
it("restarts one member only with the admin's password", async () => {
users.tmuxRestart.mockResolvedValue({});
render(<MemberTmuxCard isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
const go = screen.getByRole("button", { name: "Restart" });
expect(go).toBeDisabled();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(go);
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
});
it("restarts every idle outdated member at once, the working one left out", async () => {
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
render(<MemberTmuxCard isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
});
});
@@ -0,0 +1,27 @@
/**
* @file usePrefersReducedMotion.ts
* @description The OS "reduce motion" preference, followed live. Its own
* module so a settings screen can read it without importing the pet overlay
* (`components/Sagi/Sagi.tsx`), whose chat and pane UI would otherwise be
* pulled into every page's first bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
+1
View File
@@ -115,6 +115,7 @@ export const i18nReady = i18n
"splash",
"lanes",
"users",
"admin",
"onboarding",
"artifacts",
],
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Administration",
"description": "Accounts and the server AgentHub runs on.",
"tabs": {
"users": "Users",
"server": "Server"
},
"server": {
"title": "Server",
"uptime": "up {{v}}",
"uptimeDays": "{{d}} d {{h}} h",
"uptimeHours": "{{h}} h {{m}} min",
"cpu": "CPU",
"cores": "{{n}} cores · load {{load}}",
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
"memory": "Memory",
"swap": "Swap",
"homesDisk": "Homes disk",
"homesDiskHelp": "The disk that holds the users' home folders.",
"free": "free",
"usageTitle": "Usage by user",
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
"who": "User",
"processes": "Processes",
"home": "Home",
"sampling": "Sampling…",
"hostGroup": "Host account (AgentHub and other host processes)",
"systemGroup": "System (root and other accounts)",
"disks": "Disks",
"mount": "Mount",
"device": "Device",
"used": "Used",
"homesBadge": "homes",
"gpus": "GPUs"
}
}
+38 -3
View File
@@ -29,8 +29,10 @@
"shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub",
"open": "Open",
"copyLink": "Copy link",
"preview": "Preview",
"download": "Download",
"noPreview": "This file type cannot be shown here — download it to open it.",
"install": "Install",
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
@@ -41,7 +43,7 @@
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
"deleteConfirm": "Delete",
"installTitle": "Install “{{title}}”?",
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/, so every AI tool and session finds it. A skill can carry scripts your agent will run — only install what you trust:",
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
"installConfirm": "Install",
"replaceTitle": "Replace your skill “{{name}}”?",
@@ -76,7 +78,7 @@
"skill.tab.write": "Write new",
"skill.tab.existing": "From my skills",
"skill.name": "Skill name",
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ (and ~/.agents/skills/) and runs as /{{slug}}",
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
"skill.description": "Description — when Claude should use this skill",
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
@@ -108,5 +110,38 @@
"file.fromComputer": "From your computer (≤ 25 MB)",
"file.upload": "Upload",
"file.tooBig": "The file is larger than 25 MB.",
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote",
"editTitle": "Edit “{{title}}”",
"edit.title": "Title",
"edit.description": "Description",
"previewFailed": "Could not load this file.",
"truncated": "Showing the first 1 MB — download the file to see all of it.",
"tab.all": "All",
"installedBadge": "Installed",
"installsTitle": "Installs",
"installsYou": "You",
"installsUnknown": "A removed user",
"installsReplaced": "replaced an older copy",
"installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
"reinstall": "Reinstall",
"reinstallHint": "Write this version over your installed copy",
"reinstallTitle": "Reinstall “{{title}}”?",
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
"deleteKept": "Removed from your library — still in the Hub",
"hubOnly": "Hub only",
"hubOnlyHint": "You removed it from your library; it stays shared in the Hub",
"hubRemove": "Remove from Hub",
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
"edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.",
"preview.instructions": "Instructions",
"preview.frontmatter": "Frontmatter (SKILL.md header)",
"preview.whenUsed": "When Claude uses it",
"preview.noDescription": "No description",
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/"
}
+29 -2
View File
@@ -220,6 +220,15 @@
"clone.remember": "Remember this login for my account",
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
"clone.sshSettings": "Open Settings → Git",
"clone.partial": "Partial clone (faster for big repos)",
"clone.partialHint": "Downloads every commit but a file's contents only when it is checked out (--filter=blob:none). Checking out an old revision later fetches from the remote.",
"clone.queued": "Waiting in the clone queue — #{{position}} of {{waiting}} ({{running}}/{{limit}} clones running)",
"clone.connecting": "Connecting…",
"clone.phase.remote": "Server preparing",
"clone.phase.receiving": "Downloading",
"clone.phase.resolving": "Unpacking",
"clone.phase.checkout": "Writing files",
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
@@ -298,6 +307,7 @@
"changes.undoCommitTitle": "Undo the last commit?",
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
"changes.undoCommitConfirm": "Undo commit",
"changes.undoCommitPushed": "The last commit is already on the remote branch (as of the last fetch); undoing it would rewrite shared history. Revert it instead — or Fetch first if the remote no longer has it.",
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
"changes.amendTitle": "Amend the last commit?",
@@ -421,6 +431,9 @@
"home.cloneRun": "Clone",
"home.cloneRetry": "Retry the failed ones",
"home.cloneDone": "Added",
"home.cloneNeedSsh": "SSH links need an SSH key first — your account has none. Make one in Settings → Git and add its public key to {{hosts}} before cloning.",
"home.cloneNeedLogin": "No saved login for {{hosts}}. A private repository needs your username and an access token (or save one in Settings → Git).",
"home.cloneEnterLogin": "Enter a login",
"home.suggested": "Untracked worktrees",
"home.remove": "Remove",
"home.missing": "missing",
@@ -548,6 +561,9 @@
"fx.refresh": "Refresh",
"fx.newFolder": "New folder",
"fx.newFile": "New file",
"fx.cloneRepo": "Clone repository",
"fx.cloneSubmit": "Clone",
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
"fx.uploadFiles": "Upload files",
"fx.uploadFolder": "Upload a folder",
"fx.download": "Download",
@@ -577,7 +593,7 @@
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
"fx.pasted": "Pasted.",
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `sudo agenthub server install-system` once.",
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
"fx.conflictTitle": "Already there",
"fx.conflictBody": "This name is taken in the destination: {{names}}",
"fx.conflictAll": "Do this for every remaining file",
@@ -591,6 +607,17 @@
"fx.purge": "Delete forever",
"fx.restored": "Restored.",
"fx.trashEmpty": "The Trash is empty.",
"fx.trashOf": "{{name}}'s Trash",
"fx.myTrash": "My Trash",
"fx.trashAt": "Trash ({{name}})",
"fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.",
"fx.trashFolderEmpty": "Empty",
"fx.openTrash": "Open Trash",
"fx.purged": "Deleted forever.",
"fx.emptyTrashTitle": "Empty the Trash?",
"fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.",
"fx.purgeTitle": "Delete forever?",
"fx.purgeBody": "“{{name}}” in {{where}} will be deleted forever. This cannot be undone.",
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
"changes.openInExplorer": "Open in File Explorer",
"fx.places": "Workspaces",
@@ -620,7 +647,7 @@
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
"fx.myHome": "Home",
"fx.userHomes": "Users' homes",
"fx.readOnlyHome": "Another user's home — view and download only",
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
+1
View File
@@ -87,5 +87,6 @@
"sidebar": "Sidebar",
"now": "now",
"users": "Users",
"admin": "Administration",
"files": "Files"
}
+8
View File
@@ -50,6 +50,9 @@
"permissionAuto": "auto (recommended - asks only for risky actions)",
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
"bypass": "Bypass permissions for this run",
"yolo": "YOLO",
"yoloHint": "YOLO mode for this session: no sandbox, no approvals (--dangerously-bypass-approvals-and-sandbox)",
"bypassShort": "Bypass",
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)",
@@ -68,6 +71,8 @@
"paste": "Drop to paste into the terminal",
"uploading": "Uploading…",
"failed": "Could not attach the file",
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
"remove": "Remove attachment",
"view": "View image"
},
@@ -94,6 +99,9 @@
"notStopped": "The terminal is not connected — nothing was stopped.",
"add": "Add files, skills…",
"upload": "Upload or add files",
"pickLocal": "Choose a file on this computer",
"pickLocalHint": "Attach a file on this machine by its path — nothing is uploaded",
"pickLocalOpen": "The file picker is open on this computer's screen…",
"projectFiles": "Project files",
"contextFiles": "Context files",
"recentUploads": "Recent uploads",
+21 -1
View File
@@ -210,7 +210,27 @@
"moreSteps_other": "+{{count}} more",
"quiet": "no new activity for {{time}}",
"askDeclined": "Declined — no answer given",
"turnDone": "Done in {{time}}"
"turnDone": "Done in {{time}}",
"turnReplied": "Replied in {{time}}",
"agentsStill_one": "{{count}} agent still running",
"agentsStill_other": "{{count}} agents still running",
"agentsRunning_one": "{{count}} agent running in the background",
"agentsRunning_other": "{{count}} agents running in the background",
"agentContext": "{{tokens}} tokens",
"agent": {
"open": "View conversation",
"prompt": "Task",
"background": "background",
"toolUses_one": "{{count}} tool call",
"toolUses_other": "{{count}} tool calls",
"status": {
"running": "running",
"done": "done",
"error": "failed",
"stopped": "stopped"
},
"hide": "Hide conversation"
}
},
"overview": {
"agent": "Agent",
+77 -13
View File
@@ -37,18 +37,14 @@
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
"panel": {
"label": "{{name}} companion",
"live": "Live",
"offline": "Offline",
"close": "Close {{name}}",
"statLive": "live",
"statWaiting": "waiting",
"statErrored": "errored",
"mute": "Mute",
"unmute": "Unmute",
"clearAlerts": "Clear alerts",
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
"ask": "Ask {{name}}",
"send": "Send",
"rename": "Click to rename {{name}}",
"renameLabel": "Pet name",
@@ -217,9 +213,12 @@
},
"chat": {
"label": "Chat with {{name}}",
"back": "Back",
"close": "Close {{name}}",
"new": "New chat",
"status": "Status — click to show the sessions that need you",
"endTitle": "End this chat?",
"endBody": "It is still writing a reply. A new chat stops this session.",
"endConfirm": "Stop and start new",
"openWorkspace": "Open in workspace",
"typing": "typing…",
"placeholder": "Ask {{name}} anything…",
@@ -901,8 +900,8 @@
"title": "Your git identity",
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
"sshTitle": "SSH key",
"generate": "Generate SSH key",
"sshTitle": "SSH keys",
"generate": "Generate key",
"replace": "Replace key",
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
"keyMade": "SSH key ready — add its public key to your git host.",
@@ -910,17 +909,51 @@
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only, and clone as ssh://<SSH key ID>@git-codecommit…, not git@). Paste the whole line as one line. The private key never leaves the server.",
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
"noKey": "No key yet. Generate one to start.",
"httpsTitle": "HTTPS logins",
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
"keyType": "Key type",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)",
"terminal": "Use this key for git in the terminal",
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal."
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "ED25519 key — the git@… SSH address",
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
"awsTitle": "AWS CodeCommit",
"awsSub": "RSA 4096 key — AWS accepts only RSA",
"stepCopy": "Copy the public key.",
"awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.",
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:",
"awsKeyId": "SSH key ID",
"awsRegion": "Region",
"awsSave": "Save",
"awsSaved": "SSH key ID saved for {{region}}",
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
"otherTitle": "Other SSH hosts (advanced)",
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
"otherHost": "Host",
"otherUser": "User name",
"otherSaved": "SSH user saved for {{host}}",
"privateNote": "Only the public key is ever shown; the private key never leaves the server.",
"anyKey": "Any key",
"defaultEd": "ED25519 (default)",
"defaultRsa": "RSA (default)",
"loginKey": "Key",
"alias": "Account name",
"aliasPlaceholder": "name (optional)",
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
"moreKeysTitle": "More SSH keys",
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
"newKey": "New key",
"keyName": "Key name",
"keyNamePlaceholder": "e.g. aws-prod",
"keyType": "Key type",
"tabGit": "GitHub · GitLab",
"tabMore": "More keys",
"tabOther": "Other hosts"
},
"gitCreds": {
"title": "Git credentials",
@@ -1183,6 +1216,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.",
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
},
"retention": {
"title": "Automatic cleanup",
"description": "Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.",
"days": "Delete sessions older than",
"off": "Automatic cleanup is off.",
"on": "Sessions older than {{days}} days are deleted every hour.",
"invalid": "A whole number from 0 (off) to {{max}}",
"saved": "Automatic cleanup saved",
"lastRun": "Last run {{time}}: {{n}} session(s) deleted",
"space": "Database {{size}} · {{free}} free inside it · auto-vacuum: {{mode}}",
"compact": "Compact now",
"compacting": "Compacting…",
"compactConfirmTitle": "Compact the database?",
"compactConfirmBody": "This rewrites the whole database file to give free space back to the disk and turns on incremental auto-vacuum. On a large database it takes a while, and new hook events wait until it finishes.",
"compactDone": "Database compacted: {{before}} → {{after}}",
"compactFailed": "Compact failed: {{error}}"
},
"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.",
@@ -1257,6 +1307,20 @@
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"saveFailed": "Could not save: {{message}}"
},
"sessionDefaults": {
"title": "Session defaults",
"description": "What a new session starts with in a workspace's prompt box. Saved for your account only; a pick in the prompt box changes only that one session.",
"claudeMode": "Claude Code permission mode",
"claudeModeDesc": "Bypass permissions lets every new Claude session run any tool without asking.",
"codexSandbox": "Codex sandbox",
"codexApproval": "Codex approval",
"codexBypass": "Codex: YOLO mode",
"codexBypassDesc": "Every new Codex session starts in YOLO mode — `--dangerously-bypass-approvals-and-sandbox`: no sandbox, no approvals.",
"confirmTitle": "Make bypass the default for {{tool}}?",
"confirmBody": "Every new {{tool}} session you start will run commands and edit files without asking, until you change this back. Use it only with folders and prompts you trust.",
"confirmLabel": "Make it the default",
"codexConfirmTitle": "Make YOLO the default for Codex?"
},
"agentInstructions": {
"title": "My agent instructions",
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
+51 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
"ENOTFOUND": "That saved login no longer exists.",
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
"EUPSTREAM": "Anthropic could not be reached. Try again."
"EUPSTREAM": "Anthropic could not be reached. Try again.",
"ENOUSER": "That user no longer exists."
},
"source": "From",
"sources": {
@@ -196,7 +197,20 @@
"token": "The sk-ant-… that `claude setup-token` printed.",
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
},
"refreshFailed": "refresh failed"
"refreshFailed": "refresh failed",
"pinned": "Used by: {{users}}",
"loadingUsers": "Loading…",
"pinDone": {
"own-login": "{{user}} signed in with their own login, which was left alone",
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
},
"edit": "Edit {{name}}",
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
"save": "Save",
"users": "Users on this account",
"noUsers": "No users yet.",
"onAccount": "now on {{account}}",
"editDone": "{{name}} saved"
},
"delete": {
"button": "Delete user",
@@ -204,5 +218,40 @@
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
"confirm": "Delete for good",
"needsPassword": "Enter your own password below first."
},
"tmux": {
"title": "Members' tmux",
"description": "Each member's own tmux server keeps the version it started with until its last pane closes. Installed: tmux {{version}}.",
"refresh": "Refresh",
"notMatched": "Run `agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.",
"empty": "No member has a tmux server running.",
"user": "User",
"version": "tmux",
"sessions": "Sessions",
"agents": "Agents",
"stuck": "not answering",
"outdated": "older than installed",
"since": "since {{at}}",
"working_one": "{{count}} working",
"working_other": "{{count}} working",
"idle": "idle",
"restart": "Restart {{name}}'s tmux",
"restartIdle_one": "Restart {{count}} idle member",
"restartIdle_other": "Restart {{count}} idle members",
"restartIdleTitle": "Restart idle members' tmux",
"restartHint": "Every pane of {{name}} closes ({{count}} sessions). Their Claude sessions resume from the workspace, and the next start runs the installed tmux.",
"restartHintBusy": "An agent of {{name}} is working right now and will be stopped. Every pane of theirs closes ({{count}} sessions); their Claude sessions resume from the workspace.",
"restartIdleHint": "Restarts the tmux of every member on an older version with no agent working: {{names}}. Their panes close; their Claude sessions resume from the workspace.",
"restartButton": "Restart",
"done": "{{name}}'s tmux restarted",
"doneIdle": "Restarted: {{names}}",
"errors": {
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
}
},
"rowMenu": {
"openHome": "Open home in Files",
"copyUsername": "Copy username",
"copyEmail": "Copy email"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Quản trị",
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
"tabs": {
"users": "Người dùng",
"server": "Máy chủ"
},
"server": {
"title": "Máy chủ",
"uptime": "đã chạy {{v}}",
"uptimeDays": "{{d}} ngày {{h}} giờ",
"uptimeHours": "{{h}} giờ {{m}} phút",
"cpu": "CPU",
"cores": "{{n}} lõi · tải {{load}}",
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
"memory": "Bộ nhớ",
"swap": "Swap",
"homesDisk": "Ổ chứa thư mục người dùng",
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
"free": "trống",
"usageTitle": "Mức sử dụng theo người dùng",
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
"who": "Người dùng",
"processes": "Tiến trình",
"home": "Thư mục home",
"sampling": "Đang lấy mẫu…",
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
"systemGroup": "Hệ thống (root và tài khoản khác)",
"disks": "Ổ đĩa",
"mount": "Điểm gắn",
"device": "Thiết bị",
"used": "Đã dùng",
"homesBadge": "home",
"gpus": "GPU"
}
}
+38 -3
View File
@@ -29,8 +29,10 @@
"shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub",
"open": "Mở",
"copyLink": "Sao chép link",
"preview": "Xem",
"download": "Tải về",
"noPreview": "Không xem trước được loại file này — hãy tải về để mở.",
"install": "Cài",
"installs": "{{count}} lượt cài",
"installed": "Đã cài vào {{path}}",
@@ -41,7 +43,7 @@
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
"deleteConfirm": "Xoá",
"installTitle": "Cài “{{title}}”?",
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ của chính bạn, để mọi AI tool và mọi phiên đều dùng được. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
"installConfirm": "Cài",
"replaceTitle": "Thay skill “{{name}}” của bạn?",
@@ -76,7 +78,7 @@
"skill.tab.write": "Viết mới",
"skill.tab.existing": "Từ skill có sẵn",
"skill.name": "Tên skill",
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ (và ~/.agents/skills/) và gọi bằng /{{slug}}",
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
@@ -108,5 +110,38 @@
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
"file.upload": "Tải lên",
"file.tooBig": "File lớn hơn 25 MB.",
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi",
"editTitle": "Sửa “{{title}}”",
"edit.title": "Tiêu đề",
"edit.description": "Mô tả",
"previewFailed": "Không tải được file này.",
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
"tab.all": "Tất cả",
"installedBadge": "Đã cài",
"installsTitle": "Lượt cài",
"installsYou": "Bạn",
"installsUnknown": "Người dùng đã xoá",
"installsReplaced": "thay bản cũ",
"installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
"reinstall": "Cài lại",
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
"reinstallTitle": "Cài lại “{{title}}”?",
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
"hubOnly": "Chỉ trên Hub",
"hubOnlyHint": "Bạn đã xoá khỏi thư viện; mục này vẫn được chia sẻ trên Hub",
"hubRemove": "Gỡ khỏi Hub",
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.",
"preview.instructions": "Hướng dẫn",
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
"preview.whenUsed": "Khi nào Claude dùng",
"preview.noDescription": "Chưa có mô tả",
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/"
}
+29 -2
View File
@@ -220,6 +220,15 @@
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
"clone.sshSettings": "Mở Cài đặt → Git",
"clone.partial": "Clone một phần (nhanh hơn với repo lớn)",
"clone.partialHint": "Tải toàn bộ commit nhưng chỉ tải nội dung file khi cần checkout (--filter=blob:none). Checkout một phiên bản cũ sau này sẽ tải thêm từ remote.",
"clone.queued": "Đang chờ trong hàng clone — thứ {{position}}/{{waiting}} ({{running}}/{{limit}} clone đang chạy)",
"clone.connecting": "Đang kết nối…",
"clone.phase.remote": "Máy chủ đang chuẩn bị",
"clone.phase.receiving": "Đang tải về",
"clone.phase.resolving": "Đang giải nén",
"clone.phase.checkout": "Đang ghi file",
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
@@ -298,6 +307,7 @@
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
"changes.undoCommitConfirm": "Hoàn tác commit",
"changes.undoCommitPushed": "Commit cuối đã có trên nhánh remote (theo lần fetch gần nhất); hoàn tác sẽ viết lại lịch sử đã chia sẻ. Hãy Revert — hoặc Fetch trước nếu remote không còn commit này.",
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
"changes.amendTitle": "Amend commit cuối?",
@@ -421,6 +431,9 @@
"home.cloneRun": "Clone",
"home.cloneRetry": "Thử lại các repo lỗi",
"home.cloneDone": "Đã thêm",
"home.cloneNeedSsh": "Link SSH cần SSH key trước — tài khoản của bạn chưa có. Hãy tạo key trong Cài đặt → Git và thêm public key vào {{hosts}} trước khi clone.",
"home.cloneNeedLogin": "Chưa lưu login cho {{hosts}}. Repo private cần username và access token của bạn (hoặc lưu sẵn trong Cài đặt → Git).",
"home.cloneEnterLogin": "Nhập login",
"home.suggested": "Worktree chưa theo dõi",
"home.remove": "Gỡ",
"home.missing": "không còn",
@@ -548,6 +561,9 @@
"fx.refresh": "Làm mới",
"fx.newFolder": "Thư mục mới",
"fx.newFile": "File mới",
"fx.cloneRepo": "Clone repository",
"fx.cloneSubmit": "Clone",
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
"fx.uploadFiles": "Tải file lên",
"fx.uploadFolder": "Tải cả thư mục lên",
"fx.download": "Tải về",
@@ -577,7 +593,7 @@
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
"fx.pasted": "Đã dán.",
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `sudo agenthub server install-system` một lần.",
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
"fx.conflictTitle": "Đã có sẵn",
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
@@ -591,6 +607,17 @@
"fx.purge": "Xoá vĩnh viễn",
"fx.restored": "Đã khôi phục.",
"fx.trashEmpty": "Thùng rác trống.",
"fx.trashOf": "Thùng rác của {{name}}",
"fx.myTrash": "Thùng rác của tôi",
"fx.trashAt": "Thùng rác ({{name}})",
"fx.trashFoldersHint": "Mỗi thư mục là Thùng rác của một user — mở ra để khôi phục hoặc xoá vĩnh viễn.",
"fx.trashFolderEmpty": "Trống",
"fx.openTrash": "Mở Thùng rác",
"fx.purged": "Đã xoá vĩnh viễn.",
"fx.emptyTrashTitle": "Dọn sạch Thùng rác?",
"fx.emptyTrashBody": "Toàn bộ {{count}} mục trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
"fx.purgeTitle": "Xoá vĩnh viễn?",
"fx.purgeBody": "“{{name}}” trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
"changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
"changes.openInExplorer": "Mở trong Quản lý file",
"fx.places": "Workspace",
@@ -620,7 +647,7 @@
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
"fx.myHome": "Thư mục home",
"fx.userHomes": "Home của các user",
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
+1
View File
@@ -87,5 +87,6 @@
"sidebar": "Thanh bên",
"now": "bây giờ",
"users": "Người dùng",
"admin": "Quản trị",
"files": "Quản lý file"
}
+8
View File
@@ -50,6 +50,9 @@
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"bypass": "Bỏ qua xin quyền cho lần chạy này",
"yolo": "YOLO",
"yoloHint": "Chế độ YOLO cho phiên này: không sandbox, không hỏi phê duyệt (--dangerously-bypass-approvals-and-sandbox)",
"bypassShort": "Bỏ qua quyền",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Chế độ quyền",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
@@ -68,6 +71,8 @@
"paste": "Thả để dán vào terminal",
"uploading": "Đang tải lên…",
"failed": "Không đính kèm được file",
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
"remove": "Bỏ đính kèm",
"view": "Xem ảnh"
},
@@ -94,6 +99,9 @@
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
"add": "Thêm file, skill…",
"upload": "Tải lên hoặc thêm file",
"pickLocal": "Chọn file trên máy này",
"pickLocalHint": "Đính kèm file trên máy này bằng đường dẫn — không tải lên gì cả",
"pickLocalOpen": "Hộp chọn file đang mở trên màn hình máy này…",
"projectFiles": "File trong dự án",
"contextFiles": "File ngữ cảnh",
"recentUploads": "Đã tải lên gần đây",
+21 -1
View File
@@ -210,7 +210,27 @@
"moreSteps_other": "+{{count}} việc nữa",
"quiet": "không có hoạt động mới trong {{time}}",
"askDeclined": "Đã từ chối — không trả lời",
"turnDone": "Xong trong {{time}}"
"turnDone": "Xong trong {{time}}",
"turnReplied": "Đã trả lời trong {{time}}",
"agentsStill_one": "còn {{count}} agent đang chạy",
"agentsStill_other": "còn {{count}} agent đang chạy",
"agentsRunning_one": "{{count}} agent đang chạy nền",
"agentsRunning_other": "{{count}} agent đang chạy nền",
"agentContext": "{{tokens}} token",
"agent": {
"open": "Xem hội thoại",
"prompt": "Nhiệm vụ",
"background": "chạy nền",
"toolUses_one": "{{count}} lần gọi tool",
"toolUses_other": "{{count}} lần gọi tool",
"status": {
"running": "đang chạy",
"done": "xong",
"error": "lỗi",
"stopped": "đã dừng"
},
"hide": "Ẩn hội thoại"
}
},
"overview": {
"agent": "Agent",
+76 -12
View File
@@ -37,18 +37,14 @@
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
"panel": {
"label": "Trợ lý {{name}}",
"live": "Trực tuyến",
"offline": "Ngoại tuyến",
"close": "Đóng {{name}}",
"statLive": "đang chạy",
"statWaiting": "đang chờ",
"statErrored": "lỗi",
"mute": "Tắt tiếng",
"unmute": "Bật tiếng",
"clearAlerts": "Xoá cảnh báo",
"askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)",
"ask": "Hỏi {{name}}",
"send": "Gửi",
"rename": "Bấm để đổi tên {{name}}",
"renameLabel": "Tên pet",
@@ -217,9 +213,12 @@
},
"chat": {
"label": "Trò chuyện với {{name}}",
"back": "Quay lại",
"close": "Đóng {{name}}",
"new": "Cuộc trò chuyện mới",
"status": "Trạng thái — bấm để xem các phiên cần bạn",
"endTitle": "Kết thúc chat này?",
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
"endConfirm": "Dừng và bắt đầu mới",
"openWorkspace": "Mở trong workspace",
"typing": "đang trả lời…",
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
@@ -902,7 +901,7 @@
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
"sshTitle": "SSH key",
"generate": "Tạo SSH key",
"generate": "Tạo key",
"replace": "Thay key",
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
@@ -910,17 +909,51 @@
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA, và clone bằng ssh://<SSH key ID>@git-codecommit…, không phải git@). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
"noKey": "Chưa có key. Bấm Tạo key để bắt đầu.",
"httpsTitle": "Đăng nhập HTTPS",
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
"keyType": "Loại key",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)",
"terminal": "Dùng key này cho git trong terminal",
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal."
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
"awsTitle": "AWS CodeCommit",
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
"stepCopy": "Copy public key.",
"awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.",
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:",
"awsKeyId": "SSH key ID",
"awsRegion": "Region",
"awsSave": "Lưu",
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
"otherTitle": "Host SSH khác (nâng cao)",
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
"otherHost": "Host",
"otherUser": "Tên user",
"otherSaved": "Đã lưu SSH user cho {{host}}",
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server.",
"anyKey": "Mọi key",
"defaultEd": "ED25519 (mặc định)",
"defaultRsa": "RSA (mặc định)",
"loginKey": "Key",
"alias": "Tên tài khoản",
"aliasPlaceholder": "tên (tuỳ chọn)",
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
"moreKeysTitle": "Thêm SSH key",
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
"newKey": "Key mới",
"keyName": "Tên key",
"keyNamePlaceholder": "vd aws-prod",
"keyType": "Loại key",
"tabGit": "GitHub · GitLab",
"tabMore": "Key khác",
"tabOther": "Host khác"
},
"gitCreds": {
"title": "Git credentials",
@@ -1183,6 +1216,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.",
"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."
},
"retention": {
"title": "Tự động dọn dẹp",
"description": "Mặc định tắt. Khi bật, bảo trì hằng giờ xóa các phiên đã kết thúc (hoàn tất, lỗi, bỏ dở) bắt đầu cách đây hơn số ngày này — cùng sự kiện, agent và token của chúng — từng ít một.",
"days": "Xóa phiên cũ hơn",
"off": "Tự động dọn dẹp đang tắt.",
"on": "Phiên cũ hơn {{days}} ngày được xóa mỗi giờ.",
"invalid": "Số nguyên từ 0 (tắt) đến {{max}}",
"saved": "Đã lưu tự động dọn dẹp",
"lastRun": "Lần chạy gần nhất {{time}}: đã xóa {{n}} phiên",
"space": "Cơ sở dữ liệu {{size}} · {{free}} trống bên trong · auto-vacuum: {{mode}}",
"compact": "Nén ngay",
"compacting": "Đang nén…",
"compactConfirmTitle": "Nén cơ sở dữ liệu?",
"compactConfirmBody": "Thao tác này ghi lại toàn bộ tệp cơ sở dữ liệu để trả dung lượng trống cho ổ đĩa và bật auto-vacuum tăng dần. Với cơ sở dữ liệu lớn sẽ mất một lúc, và sự kiện hook mới phải chờ đến khi xong.",
"compactDone": "Đã nén cơ sở dữ liệu: {{before}} → {{after}}",
"compactFailed": "Nén thất bại: {{error}}"
},
"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.",
@@ -1257,6 +1307,20 @@
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"saveFailed": "Không lưu được: {{message}}"
},
"sessionDefaults": {
"title": "Mặc định cho phiên mới",
"description": "Phiên mới bắt đầu với các lựa chọn này trong ô prompt của workspace. Chỉ lưu cho tài khoản của bạn; chọn khác trong ô prompt chỉ đổi cho phiên đó.",
"claudeMode": "Chế độ quyền của Claude Code",
"claudeModeDesc": "Bỏ qua quyền cho mọi phiên Claude mới chạy mọi tool mà không hỏi.",
"codexSandbox": "Sandbox của Codex",
"codexApproval": "Phê duyệt của Codex",
"codexBypass": "Codex: chế độ YOLO",
"codexBypassDesc": "Mọi phiên Codex mới chạy ở chế độ YOLO — `--dangerously-bypass-approvals-and-sandbox`: không sandbox, không hỏi phê duyệt.",
"confirmTitle": "Đặt bỏ qua quyền làm mặc định cho {{tool}}?",
"confirmBody": "Mọi phiên {{tool}} mới bạn mở sẽ chạy lệnh và sửa file mà không hỏi, cho tới khi bạn đổi lại. Chỉ dùng với thư mục và prompt bạn tin tưởng.",
"confirmLabel": "Đặt làm mặc định",
"codexConfirmTitle": "Đặt YOLO làm mặc định cho Codex?"
},
"agentInstructions": {
"title": "Hướng dẫn agent của tôi",
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
+51 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
"ENOUSER": "User đó không còn tồn tại."
},
"source": "Nguồn",
"sources": {
@@ -196,7 +197,20 @@
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
},
"refreshFailed": "làm mới lỗi"
"refreshFailed": "làm mới lỗi",
"pinned": "Đang dùng: {{users}}",
"loadingUsers": "Đang tải…",
"pinDone": {
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
},
"edit": "Sửa {{name}}",
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
"save": "Lưu",
"users": "User dùng tài khoản này",
"noUsers": "Chưa có user nào.",
"onAccount": "đang ở {{account}}",
"editDone": "Đã lưu {{name}}"
},
"delete": {
"button": "Xoá user",
@@ -204,5 +218,40 @@
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
"confirm": "Xoá vĩnh viễn",
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
},
"tmux": {
"title": "Tmux của member",
"description": "Mỗi member có tmux server riêng, giữ phiên bản lúc nó khởi động cho đến khi pane cuối cùng đóng. Bản đã cài: tmux {{version}}.",
"refresh": "Làm mới",
"notMatched": "Chạy `agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.",
"empty": "Không member nào đang chạy tmux server.",
"user": "Người dùng",
"version": "tmux",
"sessions": "Session",
"agents": "Agent",
"stuck": "không trả lời",
"outdated": "cũ hơn bản cài",
"since": "từ {{at}}",
"working_one": "{{count}} đang chạy",
"working_other": "{{count}} đang chạy",
"idle": "rảnh",
"restart": "Khởi động lại tmux của {{name}}",
"restartIdle_one": "Khởi động lại {{count}} member đang rảnh",
"restartIdle_other": "Khởi động lại {{count}} member đang rảnh",
"restartIdleTitle": "Khởi động lại tmux của member đang rảnh",
"restartHint": "Mọi pane của {{name}} sẽ đóng ({{count}} session). Phiên Claude của họ resume lại được từ workspace, và lần Start sau sẽ chạy tmux bản đã cài.",
"restartHintBusy": "Agent của {{name}} đang chạy và sẽ bị dừng. Mọi pane của họ sẽ đóng ({{count}} session); phiên Claude resume lại được từ workspace.",
"restartIdleHint": "Khởi động lại tmux của mọi member đang chạy bản cũ mà không có agent nào đang chạy: {{names}}. Pane của họ sẽ đóng; phiên Claude resume lại được từ workspace.",
"restartButton": "Khởi động lại",
"done": "Đã khởi động lại tmux của {{name}}",
"doneIdle": "Đã khởi động lại: {{names}}",
"errors": {
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
}
},
"rowMenu": {
"openHome": "Mở home trong Files",
"copyUsername": "Sao chép username",
"copyEmail": "Sao chép email"
}
}
+53 -1
View File
@@ -207,6 +207,35 @@ describe("panePaste", () => {
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
});
it("reads Claude's unnumbered trust prompt and picks by arrows", () => {
vi.useFakeTimers();
const m = parsePaneMenu(TRUST)!;
expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]);
expect(m.options.every((o) => o.n === null)).toBe(true);
expect(m.cursor).toBe(0);
expect(m.question.split("\n")[0]).toBe("Accessing workspace:");
expect(m.question).toContain("Security guide");
const keys: string[] = [];
const off = registerPane(
"agenthub-lane-12",
() => {},
() => true,
(k) => keys.push(k)
);
expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\r"]);
off();
vi.useRealTimers();
});
it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => {
expect(parsePaneMenu(CLAUDE)).toBeNull();
expect(
parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE])
).toBeNull();
});
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
// No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
" enteresc",
];
// Claude Code's folder-trust prompt: no numbers, its footer dim (left out).
const TRUST = [
RULE,
" Accessing workspace:",
"",
" /home/me",
"",
" Quick safety check: Is this a project you created or one you trust?",
"",
" Security guide",
"",
" ❯ No, exit",
" Yes, I trust this folder",
"",
"",
];
describe("MENU_CURSOR_ROW", () => {
// The chat's 700 ms poll reads the screen cell by cell only when a row
// matches it, so every menu parsePaneMenu finds must have such a row.
it("matches a row of every menu, and no row of an idle prompt", () => {
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) {
for (const menu of [
ASK,
MULTI,
PERMISSION,
CODEX_APPROVAL,
[...TRUST, " Enter to confirm · Esc to cancel"],
]) {
expect(parsePaneMenu(menu)).not.toBeNull();
expect(has(menu)).toBe(true);
}
@@ -0,0 +1,34 @@
/**
* @file sessionChange.test.ts
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
* and on a change of the keyed columns, never on a re-broadcast that moved none.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { sessionChangeMatcher } from "../sessionChange";
import type { WSMessage } from "../types";
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
describe("sessionChangeMatcher", () => {
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
expect(
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
true
);
});
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
expect(m(msg("session_updated", null))).toBe(true);
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
});
});
+350 -45
View File
@@ -544,6 +544,71 @@ export type ServerUserUsage = AuthUser & {
logins?: number;
lastLoginAt?: string | null;
};
/** One member's own tmux server (`GET /api/users/tmux`). */
export interface MemberTmuxRow {
id: number;
username: string;
running: boolean;
/** Did not answer (hung, or backed off). */
stuck?: boolean;
version?: string | null;
pid?: number | null;
startedAt?: number | null;
sessions?: number | null;
/** Workspaces of theirs with an agent working now. */
busy: number;
/** Runs an older tmux than the installed one. */
outdated: boolean;
}
/** `GET /api/users/tmux`: `available` only with one Linux user per account. */
export interface MemberTmux {
available: boolean;
installed?: string | null;
/** Calls on an older server already use its own binary (tmux-member installed). */
matched?: boolean;
users: MemberTmuxRow[];
}
/** One account's (or the host's / other accounts') live share of the machine (`GET /api/host`). */
export interface HostUsageGroup {
/** `user:<id>`, `host` (the host account outside any workspace) or `system`. */
key: string;
kind: "user" | "host" | "system";
userId?: number;
username?: string;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number;
/** Resident memory, bytes (shared pages count in every process). */
memory: number;
memoryPercent: number;
processes: number;
/** Busiest processes, by the kernel's short name only. */
top: { pid: number; name: string; cpu: number; memory: number }[];
}
/** `GET /api/host` — hardware, load, disks and per-user usage. */
export interface HostStats {
at: string;
hostname: string;
os: { platform: string; release: string; distro: string | null; arch: string };
uptime: number;
cpu: { model: string | null; cores: number; load: number[] };
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
disks: {
device: string;
mount: string;
type: string;
kind: "HDD" | "SSD" | null;
total: number;
used: number;
free: number;
}[];
/** The mount holding the users' homes, or null. */
homesMount: string | null;
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
/** null off Linux. */
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
}
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
export type UserStorage = {
bytes: number;
@@ -611,9 +676,18 @@ export type CodexPoolAccount = {
quotaPercent: number | null;
full: boolean;
};
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
export type AccountPin = {
userId: number;
username: string;
account: string;
pinnedAt?: string;
};
export type AccountPool = {
accounts: PoolAccount[];
codex: CodexPoolAccount[];
/** Per tool; absent from an older server, empty in local mode. */
pins?: { claude: AccountPin[]; codex: AccountPin[] };
blockPercent: number;
};
export type PoolTool = "claude" | "codex";
@@ -722,6 +796,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
runId: body?.error?.run_id as string | undefined,
// EEXISTS from the File Explorer: the names already taken.
names: body?.error?.names as string[] | undefined,
// ETOOBIG from a prompt drop: the server's own limit.
maxBytes: body?.error?.max_bytes as number | undefined,
});
}
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
@@ -787,11 +863,52 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
}
/**
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
*/
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${url}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as T);
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(payload);
});
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
export interface FsHome {
id: string;
label: string;
readOnly: boolean;
/** The caller's own home only (`me`): its absolute folder — where New →
* Clone repository clones to. Absent from an older server. */
dir?: string;
}
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
@@ -817,6 +934,11 @@ export interface FsTrashItem {
path: string;
isDir: boolean;
deletedAt: string;
/** A home's recycle bin only: which of the user's trash folders holds it. */
store?: "home" | "host";
/** The merged view only (`allTrash`): the home it belongs to, and its user. */
home?: string;
owner?: string;
}
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
@@ -888,6 +1010,10 @@ export const api = {
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
host: {
stats: () => request<HostStats>("/host"),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
@@ -915,6 +1041,24 @@ export const api = {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
tmux: () => request<MemberTmux>("/users/tmux"),
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
tmuxRestart: (id: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
tmuxRestartIdle: (currentPassword: string) =>
request<{
restarted: string[];
skipped: string[];
failed: { username: string; message: string }[];
}>("/users/tmux-restart-idle", {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
get: (id: number) => request<UserDetail>(`/users/${id}`),
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
@@ -960,6 +1104,20 @@ export const api = {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
request<{
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
added: {
userId: number;
username: string;
switched: "switched" | "kept" | "own-login" | "expired" | "next-session";
}[];
removed: { userId: number; username: string }[];
}>(`/accounts/${encodeURIComponent(name)}/users`, {
method: "PUT",
body: JSON.stringify({ tool, userIds, currentPassword }),
}),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
@@ -1557,6 +1715,23 @@ export const api = {
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** Opt-in automatic retention and Compact (`server/lib/db-retention.js`). */
retention: {
/** GET /api/settings/retention */
get: () => request<RetentionState>("/settings/retention"),
/** PUT /api/settings/retention — `days` 0 turns it off */
save: (days: number) =>
request<RetentionState & { ok: boolean }>("/settings/retention", {
method: "PUT",
body: JSON.stringify({ days }),
}),
/** POST /api/settings/compact — a full VACUUM; poll `get` while `compacting` */
compact: () =>
request<RetentionState & { ok: boolean }>("/settings/compact", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
@@ -2595,15 +2770,22 @@ export const api = {
body: JSON.stringify({ paths }),
}),
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
restore: (t: FsPlace, id: string) =>
/** Every Trash the caller may open, merged (an admin: every user's). */
allTrash: () =>
request<{
items: FsTrashItem[];
errors?: { home: string; owner: string; message: string }[];
keepDays?: number;
}>("/homes/trash"),
restore: (t: FsPlace, id: string, store?: FsTrashItem["store"]) =>
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify(store ? { id, store } : { id }),
}),
purge: (t: FsPlace, id: string | "all") =>
purge: (t: FsPlace, id: string | "all", store?: FsTrashItem["store"]) =>
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify(store ? { id, store } : { id }),
}),
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
zipUrl: (t: FsPlace, paths: string[]) => {
@@ -2622,40 +2804,45 @@ export const api = {
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) =>
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
path?: string;
skipped?: boolean;
} = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as { path: string; skipped?: boolean });
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(file);
sendRaw<{ path: string; skipped?: boolean }>(
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
file,
onProgress
),
/**
* Many small files in ONE request, under the folder `dir` (each `rel`
* relative to it): a 4-byte length, the JSON file list, then the bytes.
* The Blob is only composed, never read into memory here.
*/
uploadBatch: (
t: FsPlace,
dir: string,
files: { file: Blob; rel: string }[],
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) => {
const head = new TextEncoder().encode(
JSON.stringify({
files: files.map((f) => ({
path: f.rel,
size: f.file.size,
mtime: f.file instanceof File ? f.file.lastModified : undefined,
})),
})
);
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, head.length);
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
onProgress
);
},
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
method: "POST",
body: JSON.stringify({ dir, paths }),
}),
},
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
@@ -2665,17 +2852,30 @@ export const api = {
* Make the SSH key; `replace` swaps an existing one for a new pair.
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
*/
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519", name?: string) =>
request<GitIdentity>("/me/git/ssh-key", {
method: "POST",
body: JSON.stringify({ replace, type }),
body: JSON.stringify(name ? { replace, type, name } : { replace, type }),
}),
/** DELETE /api/me/git/ssh-key — the key of `type`, or every key. */
/** DELETE /api/me/git/ssh-key — the default key of `type`, or every key. */
removeKey: (type?: "ed25519" | "rsa") =>
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
removeKeyById: (id: string) =>
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) =>
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
/**
* PUT /api/me/git/ssh-user — an SSH login (CodeCommit: the SSH key ID as
* the user): for the host itself, or with `alias` one more account on it
* (`ssh://<alias>/…`); `key` = the only key it uses.
*/
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
/** DELETE a login by its host (the host's own rule) or its alias. */
removeSshUser: (from: string) =>
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) =>
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
removeHttps: (host: string) =>
@@ -2790,18 +2990,24 @@ export const api = {
source?: string;
/** plugin only: the marketplace source. */
market?: string;
/** skill only: new contents, written as a new snapshot. */
skill?: { name: string; description: string; content: string };
}
) =>
request<{ item: Artifact }>(`/artifacts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
/** `kept`: a shared one left your library but stays in the Hub. */
remove: (id: number) =>
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
/** One of the snapshot's own files, served sandboxed. */
rawUrl: (id: number, path?: string, download = false) => {
const qs = new URLSearchParams();
@@ -2979,6 +3185,14 @@ export const api = {
headers: { "Content-Type": "application/octet-stream" },
});
},
/** POST /api/run/local-paths — each absolute path's size when it is a
* regular file on the dashboard's machine, else null; 403 EREMOTE unless
* the browser is on that machine (and never in server mode). */
localPaths: (paths: string[]) =>
request<{ sizes: (number | null)[] }>("/run/local-paths", {
method: "POST",
body: JSON.stringify({ paths }),
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
@@ -3421,11 +3635,22 @@ export const api = {
title?: string;
tool?: LaneTool;
credentials?: { username: string; password: string; remember?: boolean };
/** Blobless clone (`--filter=blob:none`): file contents fetched on checkout. */
partial?: boolean;
/** This clone's name for {@link lanes.cloneProgress}. */
progressToken?: string;
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/clone/progress/:token — a clone's place in the
* machine-wide queue, then git's own progress; 404 until the POST
* carrying `progressToken` arrives, and a minute after it ends.
*/
cloneProgress: (token: string) =>
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
/**
* GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3822,6 +4047,9 @@ export const api = {
url: string;
name?: string;
credentials?: { username: string; password: string; remember?: boolean };
/** Blobless clone, like {@link lanes.clone}'s `partial`. */
partial?: boolean;
progressToken?: string;
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
@@ -4034,10 +4262,21 @@ export interface Artifact {
shared: boolean;
sharedAt: string | null;
installs: number;
/** False once its owner removed it from their library while shared: Hub only. */
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
createdAt: string;
updatedAt: string;
}
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
export interface ArtifactInstalls {
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
/** Installs counted before the list was kept. */
unrecorded: number;
}
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
export interface MarketplaceInfo {
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
@@ -4206,12 +4445,29 @@ export interface GitIdentity {
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
terminal?: "on" | "off" | "foreign";
} | null;
/** Every key the account holds (ED25519 first); absent from an older server. */
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
/**
* Every key the account holds: the default ED25519 and RSA first, then the
* named ones (`name`; `id` = its file name); absent from an older server.
*/
sshKeys?: {
id?: string;
type?: "ed25519" | "rsa";
name?: string | null;
publicKey: string;
fingerprint: string;
}[];
/** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null;
/**
* SSH logins (`url.<…>.insteadOf` in `~/.gitconfig`): `alias` null = the
* host's own (`ssh://<host>/`), else `ssh://<alias>/`; `key` = the one key
* it uses, null = every key. Absent from an older server.
*/
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
/** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null };
/** The suggested email while none is set: `<username>@smartgift.vn`. */
defaultEmail?: string | null;
https: {
protocol: "http" | "https";
host: string;
@@ -4260,6 +4516,8 @@ export interface AiTool {
prompt: { mode: "argv" | "flag" | "after-start" } | null;
/** Offers "Resume last session". */
resume: boolean;
/** Can resume a NAMED session (Claude, Codex, a catalog `resumeById`). */
resumeById?: boolean;
/** Offers the per-run "Bypass permissions" switch. */
bypass: boolean;
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
@@ -5043,7 +5301,7 @@ export interface RunStartArgs {
cwd?: string;
model?: string;
permissionMode?: PermissionMode;
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
/** Resume an existing session id (Claude/Codex, or a catalog `resumeById` tool) instead of starting fresh. */
resumeSessionId?: string;
effort?: EffortLevel;
/** Sent as `claude`'s first positional message once the pane boots; omit
@@ -5116,6 +5374,23 @@ export interface LaneSource {
}
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
/** `GET /api/lanes/clone/progress/:token` — see server/lib/clone-queue.js. */
export interface CloneProgressView {
state: "queued" | "running" | "done" | "failed";
/** 1-based place among the waiting clones while `queued`. */
position: number | null;
/** Clones running / waiting machine-wide, and how many may run at once. */
running: number;
waiting: number;
limit: number;
phase: "remote" | "receiving" | "resolving" | "checkout" | "other" | null;
/** git's own words for the phase (shown for `other`). */
label: string | null;
percent: number | null;
/** e.g. "450/1000, 120.50 MiB | 5.20 MiB/s". */
detail: string | null;
}
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
@@ -5332,6 +5607,36 @@ export interface BackupState {
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/retention. `days` 0 = automatic retention off;
* `last_run`/`last_compact` are this server process's own. */
export interface RetentionState {
days: number;
max_days: number;
env_owned: { days: boolean };
last_run: {
at: string;
retention?: { purged_sessions: number; purged_events: number; error?: string };
reclaim?: { reclaimed_pages: number; error?: string };
} | null;
space: {
auto_vacuum: "none" | "full" | "incremental";
page_size: number;
page_count: number;
freelist_pages: number;
free_bytes: number;
} | null;
db_size: number;
compacting: boolean;
last_compact: {
at: string;
ok: boolean;
size_before?: number;
size_after?: number;
ms?: number;
error?: string;
} | null;
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
+48 -8
View File
@@ -14,6 +14,10 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* Attached images' paths go after the text, each in a paste of its own — how
* `codex` attaches one; a `claude` pane gets every file as an `@` mention in
* the text instead (`promptForPane`), since a pasted path stayed a plain
* `C:\…\drops\…png` there on Windows.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
@@ -24,7 +28,8 @@
export type PaneScreen = { rows: string[]; cols: number };
interface Pane {
paste: (text: string, submit?: boolean) => void;
/** `files`: image paths pasted after `text`, one paste each. */
paste: (text: string, submit?: boolean, files?: string[]) => void;
visible: () => boolean;
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
@@ -62,19 +67,19 @@ function ofLane(runId: string, laneId: number): boolean {
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
* false otherwise (not mounted yet, or hidden). */
export function pasteToRun(runId: string, text: string): boolean {
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text);
p.paste(text, false, files);
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 {
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text, true);
p.paste(text, true, files);
return true;
}
@@ -176,8 +181,9 @@ export type PaneMenu = {
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -224,7 +230,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
}
}
const numbered = options.filter((o) => o.n !== null);
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? ""))
return parseBareMenu(r.slice(0, end + 1));
if (numbered.some((o, i) => o.n !== i + 1)) return null;
// The question: the block of text right above the first option.
let q = top - 1;
@@ -241,6 +248,39 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
};
}
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
* `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the
* cursor row's text column, and nothing under them is a rule — Claude's input
* box draws one above and below its `❯` row, so neither the box nor a prompt
* in the history above it reads as a menu. Picked by arrows and Enter. */
function parseBareMenu(r: string[]): PaneMenu | null {
let at = -1;
for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i;
if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l)))
return null;
const m = BARE_CURSOR.exec(r[at]!)!;
const indent = " ".repeat((m[1] ?? "").length + 2);
const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " ");
let top = at;
while (top > 0 && sibling(r[top - 1])) top--;
let last = at;
while (last < r.length - 1 && sibling(r[last + 1])) last++;
if (last === top) return null;
const options = r.slice(top, last + 1).map((l, i) => ({
n: null,
label: top + i === at ? (m[2] ?? "").trim() : l.trim(),
hint: "",
checked: null,
}));
// The question: the paragraphs above, up to the dialog's top rule.
const lines: string[] = [];
for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--)
if (r[q]) lines.unshift(r[q]!.trim());
return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 };
}
/** The "Type something" row. Text typed into it replaces its label, and it is
* still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number {
+92 -24
View File
@@ -4,12 +4,17 @@
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
* `PromptEditor`'s drop) or from a Chat pane's activity panel
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
* last day (`recentUploads`, kept in this browser). An attachment is a path
* sent with the prompt, so nothing here touches the server.
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
* `@` form a file is handed to `claude` in, and what a browser on the
* dashboard's own machine attaches by path instead of uploading
* (`localCandidates`, checked by the server in `sameFilesHere` —
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
* sent with the prompt; that check is the one call here to the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { DragEvent } from "react";
import { api } from "./api";
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
export interface Attachment {
@@ -64,6 +69,76 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
}
}
/** Whether this browser reaches the dashboard by a loopback address — a
* hint only: an SSH tunnel's browser does too, from another machine, so a
* dropped path is used only once `sameFilesHere` has checked it. */
export function isLocalBrowser(): boolean {
return /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(window.location.hostname);
}
/** The `file://` paths a drop or paste carries beside its `files`, when this
* may be a browser on the dashboard's machine — read now: a DataTransfer is
* empty once its event is over. Null when there is nothing to check. */
export function localCandidates(
dt: DataTransfer | null | undefined,
files: File[]
): string[] | null {
if (!isLocalBrowser()) return null;
const paths = droppedFilePaths(dt);
return paths && paths.length === files.length ? paths : null;
}
/** Whether `paths` are the dropped `files` as the dashboard's machine sees
* them: every one a regular file there, and the sizes the same. Anything
* else (an SSH tunnel's laptop paths, server mode, an error) is false, and
* the caller uploads the bytes as before. */
export async function sameFilesHere(paths: string[], files: File[]): Promise<boolean> {
try {
const { sizes } = await api.run.localPaths(paths);
const want = files.map((f) => f.size).sort((a, b) => a - b);
const have = sizes.slice().sort((a, b) => (a ?? -1) - (b ?? -1));
return have.length === want.length && have.every((s, i) => s === want[i]);
} catch {
return false;
}
}
/** The local files a drop or a paste names as `file://` URIs (a file
* manager's, an editor's), as absolute paths — or null when it names none,
* or anything that is not one. `file:///C:/x` is `C:/x`. */
export function droppedFilePaths(dt: DataTransfer | null | undefined): string[] | null {
try {
const paths = (dt?.getData("text/uri-list") ?? "")
.split(/\r?\n/)
.filter((l) => l && !l.startsWith("#"))
.map((l) => {
try {
const u = new URL(l.trim());
if (u.protocol !== "file:" || (u.host && u.host !== "localhost")) return null;
return decodeURIComponent(u.pathname).replace(/^\/([A-Za-z]:\/)/, "$1");
} catch {
return null;
}
});
return paths.length && paths.every(Boolean) ? (paths as string[]) : null;
} catch {
return null;
}
}
/** A file on this machine, by its absolute path, as an attachment. */
export function localAttachment(path: string): Attachment {
return {
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
name:
path
.replace(/[\\/]$/, "")
.split(/[\\/]/)
.pop() || path,
};
}
/** A drop that carries no file and none of ours, only text — a file manager
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
* link: the local `file://` URIs as `@path` attachments, else the text to
@@ -72,28 +147,8 @@ export function droppedText(
dt: DataTransfer | null | undefined
): { attachments: Attachment[] } | { text: string } | null {
try {
const paths = (dt?.getData("text/uri-list") ?? "")
.split(/\r?\n/)
.filter((l) => l && !l.startsWith("#"))
.map((l) => {
try {
const u = new URL(l.trim());
return u.protocol === "file:" && (!u.host || u.host === "localhost")
? decodeURIComponent(u.pathname)
: null;
} catch {
return null;
}
});
if (paths.length && paths.every(Boolean)) {
return {
attachments: (paths as string[]).map((p) => ({
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
name: p.replace(/\/$/, "").split("/").pop() || p,
})),
};
}
const paths = droppedFilePaths(dt);
if (paths) return { attachments: paths.map(localAttachment) };
const text = dt?.getData("text/plain") ?? "";
return text.trim() ? { text } : null;
} catch {
@@ -101,6 +156,19 @@ export function droppedText(
}
}
/** An attached file as Claude Code's `@` mention, which `claude` reads at
* submit — an image as an image — whether it came on the command line or in
* a paste. A bare pasted PATH becomes `[Image #n]` only when the TUI catches
* the paste on its own and finishes before Enter: on Windows (psmux) it
* stayed a plain `C:\…\drops\…png` in the prompt, and on the command line
* a path is never an image at all. A Windows path's backslashes become `/`
* (Windows takes either); a path with a space is quoted. */
export function claudeMention(path: string): string {
if (path.startsWith("@")) return path;
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
}
export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
+32
View File
@@ -0,0 +1,32 @@
/**
* @file sessionChange.ts
* @description `session_created` / `session_updated` matcher for live-refresh
* callers that care about only a few session columns. A session row is
* re-broadcast on nearly every hook (last activity, name, tokens…), so
* matching the bare message types made every such frame refetch; this one
* fires on a new session, or when the chosen key of a session actually
* changed. Each call keeps its own memory of the keys it has seen.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { Session, WSMessage } from "./types";
/** The columns that move a session between the active / waiting / archived counts. */
export const sessionStateKeyOf = (s: Partial<Session>): string =>
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
export function sessionChangeMatcher(
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
): (msg: WSMessage) => boolean {
const seen = new Map<string, string>();
return (msg) => {
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
const s = msg.data as Partial<Session> | undefined;
// A frame we cannot key is taken as a change: refetching is the safe side.
if (!s || typeof s.id !== "string") return true;
const key = keyOf(s);
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
seen.set(s.id, key);
return true;
};
}
+49 -9
View File
@@ -1,22 +1,37 @@
/**
* @file sessionDefaults.ts
* @description The account's own starting choices for new work, picked in the
* first-run setup (docs/plans/onboarding) and synced per account like every
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
* the permission mode a new Claude session starts in. Bypass is NEVER one of
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
* is refused on read and on write.
* first-run setup (docs/plans/onboarding) or Settings → AI tools → Session
* defaults, and synced per account like every other preference
* (`lib/userPrefs.ts`): the AI a new workspace starts on, the permission mode
* a new Claude session starts in, and a new Codex session's sandbox, approval
* and bypass. Bypass (Claude's `bypassPermissions`, Codex's bypass switch) may
* be a default only because the user turned it on in Settings behind a
* confirm — the first-run setup never offers it, and the pet chat never
* starts in it (`readDefaultMode({ safe: true })`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { PermissionMode } from "./api";
import type { CodexApproval, CodexSandbox, PermissionMode } from "./api";
const TOOL_KEY = "agenthub.defaultTool";
const MODE_KEY = "agenthub.permissionMode";
const CODEX_SANDBOX_KEY = "agenthub.codex.sandbox";
const CODEX_APPROVAL_KEY = "agenthub.codex.approval";
const CODEX_BYPASS_KEY = "agenthub.codex.bypass";
/** The permission modes a default may be. */
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan", "bypassPermissions"] as const;
export type DefaultMode = (typeof DEFAULT_MODES)[number];
/** The ones the first-run setup offers: never bypass. */
export const SAFE_MODES = DEFAULT_MODES.filter((m) => m !== "bypassPermissions");
export const CODEX_SANDBOXES: readonly CodexSandbox[] = [
"read-only",
"workspace-write",
"danger-full-access",
];
export const CODEX_APPROVALS: readonly CodexApproval[] = ["on-request", "never"];
function read(key: string): string | null {
try {
@@ -37,11 +52,36 @@ function write(key: string, value: string): void {
export const readDefaultTool = (): string | null => read(TOOL_KEY);
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
export function readDefaultMode(): PermissionMode {
/** The permission mode a new Claude session starts in ("auto" when none was
* picked); `safe` turns a bypass default into "auto". */
export function readDefaultMode({ safe = false }: { safe?: boolean } = {}): PermissionMode {
const v = read(MODE_KEY);
if (safe && v === "bypassPermissions") return "auto";
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
}
export function writeDefaultMode(mode: DefaultMode): void {
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
}
export interface CodexDefaults {
sandbox: CodexSandbox;
approval: CodexApproval;
bypass: boolean;
}
/** A new Codex session's sandbox, approval and bypass (Codex's own defaults when none was picked). */
export function readCodexDefaults(): CodexDefaults {
const sandbox = read(CODEX_SANDBOX_KEY) as CodexSandbox | null;
const approval = read(CODEX_APPROVAL_KEY) as CodexApproval | null;
return {
sandbox: sandbox && CODEX_SANDBOXES.includes(sandbox) ? sandbox : "workspace-write",
approval: approval && CODEX_APPROVALS.includes(approval) ? approval : "on-request",
bypass: read(CODEX_BYPASS_KEY) === "true",
};
}
export function writeCodexDefaults(patch: Partial<CodexDefaults>): void {
if (patch.sandbox && CODEX_SANDBOXES.includes(patch.sandbox)) write(CODEX_SANDBOX_KEY, patch.sandbox);
if (patch.approval && CODEX_APPROVALS.includes(patch.approval))
write(CODEX_APPROVAL_KEY, patch.approval);
if (patch.bypass !== undefined) write(CODEX_BYPASS_KEY, String(patch.bypass === true));
}
+18 -1
View File
@@ -732,7 +732,10 @@ export interface Session {
/** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks), `"antigravity"` (agy's conversation files and
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
* `"opencode"` (their own session stores, with tokens and subagents) or
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
* `sessions.tool`. */
tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started.
@@ -3225,6 +3228,17 @@ export interface TranscriptContent {
/** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */
answers?: Record<string, string>;
/** Present on an Agent/Task "tool_result": the subagent it ran — its id
* (the transcript's `agent_id`), model and, for a foreground run, its time,
* tokens and tool calls (null when the result does not say). */
agent?: {
id: string;
model: string | null;
status: string | null;
duration_ms: number | null;
tokens: number | null;
tool_uses: number | null;
};
}
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
@@ -3310,6 +3324,9 @@ export interface TranscriptInfo {
/** The subagent type for subagent entries; null/absent otherwise (e.g. the
* main or compaction entries). */
subagent_type?: string | null;
/** The Agent call that launched this subagent (its `tool_use` id), when its
* meta file names it. */
tool_use_id?: string | null;
/** Whether a JSONL transcript file was actually found on disk for this entry -
* false means the entry exists in the DB but its transcript isn't available
* (e.g. the raw file was pruned), so the UI disables that picker option. */
+3
View File
@@ -38,6 +38,9 @@ export const PREF_KEYS = [
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.codex.sandbox",
"agenthub.codex.approval",
"agenthub.codex.bypass",
"agenthub.tour",
"agenthub.workspace.allTabs",
] as const;
+66
View File
@@ -0,0 +1,66 @@
/**
* @file Administration.tsx
* @description The Administration page (`/admin`, a sidebar item; `/users`
* redirects here), server mode only and only for an admin or analyst: a tab
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
* (the machine's hardware and each user's share of it, `ServerPanel`).
* Local mode and a developer are sent home, like the Users page always did.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { useAuth } from "../components/AuthGate";
import { ServerPanel } from "../components/admin/ServerPanel";
import { Users } from "./Users";
interface AdminTab {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
}
export function Administration() {
const { t } = useTranslation("admin");
const auth = useAuth();
const [params, setParams] = useSearchParams();
const me = auth.state.mode === "server" ? auth.state.user : null;
if (!me || me.role === "developer") return <Navigate to="/" replace />;
const tabs: AdminTab[] = [
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
{ id: "server", label: t("tabs.server"), Icon: Server },
];
const first = tabs[0]!;
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
return (
<div className="animate-fade-in space-y-6">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<ShieldCheck className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<PageTabs
ariaLabel={t("title")}
activeId={active.id}
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
tabs={tabs}
>
{active.id === "server" ? (
<ServerPanel isAdmin={me.role === "admin"} />
) : (
<Users embedded />
)}
</PageTabs>
</div>
);
}
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -2790,9 +2790,12 @@ function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Sessi
useEffect(() => {
load();
}, [load]);
useLiveRefresh(load, { intervalMs: 10000 });
// New events arrive over the WebSocket (prepended below); a reload is only
// for what the socket cannot carry: a remote source's import, a gap while it
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
// A new event is prepended at once (no reload); the poll reconciles.
// A new event is prepended at once (no reload); the reloads reconcile.
useEffect(
() =>
eventBus.subscribe((msg: WSMessage) => {
+5 -5
View File
@@ -107,7 +107,7 @@ export function GitCredentialsConfig() {
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between gap-4">
{/* The page header above already names the section. */}
<p className="max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
<button
@@ -128,7 +128,7 @@ export function GitCredentialsConfig() {
{form && (
<form
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
className="grid gap-2 rounded-lg border border-border bg-surface-1 p-3 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void save();
@@ -159,18 +159,18 @@ export function GitCredentialsConfig() {
onChange={(e) => setForm({ ...form, token: e.target.value })}
className={INPUT}
/>
<div className="flex gap-2 sm:col-span-3">
<div className="flex justify-end gap-2 sm:col-span-3">
<button
type="submit"
disabled={saving || !form.url.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
className="rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
<button
type="button"
onClick={() => setForm(null)}
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
className="rounded-lg border border-border-light px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
>
{t("gitCreds.cancel")}
</button>
+5 -1
View File
@@ -1129,7 +1129,11 @@ export function SessionDetail() {
{visitedTabs.has("conversation") && (
<div hidden={activeTab !== "conversation"}>
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
<ConversationView
sessionId={session.id}
initialTranscriptId={pendingTranscriptId}
ended={session.status !== "active"}
/>
</div>
)}
+4 -1
View File
@@ -1,6 +1,6 @@
/**
* @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`), 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>`.
* @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; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) 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>
*/
/* =============================================================================
@@ -103,6 +103,7 @@ import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { BackupSettings } from "../components/settings/BackupSettings";
import { RetentionSettings } from "../components/settings/RetentionSettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { usePermissions } from "../components/AuthGate";
import { Skeleton } from "../components/Skeleton";
@@ -1054,6 +1055,8 @@ export function Settings({
</button>
{actionBanner(["cleanup"])}
<RetentionSettings />
</section>
{/* ─── THIS BROWSER'S PREFERENCES ─── */}

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