Compare commits

...

79 Commits

Author SHA1 Message Date
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
nntrivi2001 1c5bd88747 chore: v2.8.1 — Stop archives / Sleep keeps a session, SSH key per type (#82), #83 tables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:52:32 +07:00
nntrivi2001 0a1dcfeb7f feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix
- Stop (a tab's or pane's X, Close tab) now archives the session it held
  (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in
  the tab, pane and session right-click menus) stops the pane but keeps the
  session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts.
- Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS
  CodeCommit takes only RSA), every ssh git offers both, and a member's
  ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them.
- Update dialog: Auto fix starts a Claude Code session on the install
  folder with the failed update's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00
nntrivi2001 58be04a4e7 fix(chat): render a markdown table that follows prose with no blank line (#83)
The chat renderer's paragraph collector never stopped at a table header,
so a table right after a line of text was shown as raw pipes. A table now
interrupts a paragraph as in GFM, and the divider accepts single dashes.

Closes #83

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00
Nguyễn Ngọc Trí Vĩ cd427d218b fix(terminal): psmux wheel scrolling, per-platform tmux upgrade advice, Windows-safe server tests
- Server tests on Windows no longer reach the user's real psmux: psmux
  ignores TMUX_TMPDIR (and 3.3.x PSMUX_DATA_DIR), so quit.test.js's Quit and
  the runner's kill-server ended every live lane pane — the pre-commit hook
  included. scripts/test-server.js now takes tmux off the suites' PATH there.
- Wheel scrolling on Windows: psmux never turns mouse tracking on for its
  client, so xterm.js never reported the wheel and a pane could not scroll.
  A Windows attach is told {type:"wheel"} and the browser sends the SGR wheel
  report itself; psmux scrolls or forwards it to the pane's app.
- tmux.upgradeAdvice(): the oldest tmux AgentHub asks for and how each
  platform upgrades (psmux 3.4.0 -> winget upgrade marlocarlo.psmux; tmux 3.7
  -> brew upgrade tmux / build the release on Linux). Used by the doctor,
  GET /api/run/tmux ({available, version, upgrade}), `agenthub doctor`, and a
  sticky toast on the Workspace page. Advice only: nothing installs tmux.

Committed with --no-verify (user's call): the pre-commit hook runs the full
backend suite, which has ~378 pre-existing failures on Windows (S3/Linux-only
tests: sudo, /proc, systemd, members). Every test touching this change passes:
pty-attach, tmux, run, plugin-doctor-tmux, TerminalView, Workspace, and the
client tests related to the changed files; tsc and the header audit are clean.
2026-10-15 11:46:09 +07:00
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00
nntrivi2001 8ed5018a2f chore: v2.7.9 — Add workspace has no AI tool picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:24:08 +07:00
nntrivi2001 aacd63b85b feat(workspace): drop the AI tool picker from Add workspace
A new workspace takes the default AI tool (Settings → AI Tools, the
account's own pick first); each session still picks its own AI on the
workspace home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:19:19 +07:00
nntrivi2001 22de5009ff feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:06:54 +07:00
nntrivi2001 e94038481e feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files
  tab, portalled so no panel clips it), laid out like GNOME Files: places
  (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with
  image thumbnails or list, labelled New/Upload, selection action bar, hidden
  files behind Ctrl+H, right-click menus and the usual keys.
  Make, rename, move, copy (within a workspace, between workspaces, home ↔
  workspace), delete into a per-account trash restorable for 7 days, streamed
  upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both /
  Skip on a taken name), download a file or a zip of anything.
  server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*.
- The user's home: every dot entry at its top stays hidden and refused
  (also via a symlink, download, zip or as a copy source); an admin may view
  other users' homes read-only, audited.
- write-as learns move/copy (re-run `sudo agenthub server install-system`).
- Hardening: a write to a workspace re-checks its owner by the live role;
  S3 keeps its server.conf if the file stops reading and a tmux call never
  falls back to the host's server; the AI Proxy gateway marks what it relays
  and the dashboard refuses it; a NUL in a pane's argv/env is refused.
- OpenAPI for the new routes (server/openapi-extra/files.js); docs (API,
  SERVER-MODE, CLAUDE.md) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:17:39 +07:00
nntrivi2001 b441f32949 Merge branch 'perf/lag': dashboard lag fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	client/src/components/Sagi/PetSprite.tsx
#	client/src/components/Sagi/useSagiBrain.ts
2026-10-14 12:26:41 +07:00
nntrivi2001 cb19d7e5d2 feat: file upload/download and pinned sessions, text size, git identity and clone
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):

- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
  /:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
  (git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
  the session menu, pinned first); text size setting (lib/fontSize.ts,
  Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
  routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().

Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:21:55 +07:00
nntrivi2001 0cb5e41faf feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only
  dropped it in memory, so its next update (or a reload) brought the alert
  straight back. An error now alerts on the transition into error only;
  cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay
  quiet until they recover and fail anew.
- Quips, greetings, notifications and Ask answers move to i18n (EN/VI);
  Ask understands Vietnamese keywords and names the sessions it counts.
- The pet goes by the worn pet's name; click it in the panel to rename
  (agenthub.pet.names, synced to the account).
- Bubbles name the session they are about, with an Open button (never a
  resume); a burst of the same event coalesces into one count.
- Moments: a new session waves, a finish hops, a subagent peeks, a wait taps,
  an error flinches — on Sagi, Codex sprites and still images. Idle fidgets,
  a once-per-session greeting, a "Needs you" list, an amber waiting dot.
- Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so
  it shows while the panel or chat is open.
- Panel drops Run Claude / Activity / Sessions / Errored.
- Ask hands off to a chat window (PetChat) on the default AI tool
  (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an
  off-screen attached terminal; no new server route.

Verified on HEAD + this diff in a clean worktree: tsc -b, the full client
suite (1334/1335; the one failure, ConversationView.follow, is flaky under
load and passes 3/3 alone) and server me-prefs.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:29 +07:00
nntrivi2001 1c5521cf2e perf: rest animations, cheaper chat typing and server polls, async per-tool hooks
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
  (pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
  rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
  state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
  callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
  cell by cell (reusing one cell) only when a row could be a menu cursor row.

Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
  "yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
  per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.

Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
  plugin.json, S3 managed settings): Claude Code no longer waits a Node start
  before and after every tool call.

Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:55:53 +07:00
nntrivi2001 c3f83d3922 perf(chat): hidden panes stop polling, rows stop re-rendering, a covered terminal stops drawing
The Workspace keeps panes of other workspaces and tabs mounted (display:none),
and every one of them kept following the live stream as if shown.

- useOnScreen (IntersectionObserver): ConversationView, the outline and the
  700 ms menu scan skip while their pane is hidden and catch up once on show.
- A new_event refetches the tail at most every 1.5 s (leading + trailing).
- MessageList: WorkGroup/MessageRow/EditedFiles compare only their own blocks
  and results, so a tail append no longer re-renders every row.
- A terminal under the chat is moved off the viewport with a transform, so
  xterm pauses drawing without a resize.
- PaneChat and TerminalView are memoized; LaneConsolePane passes a stable onExit.
- RunningStep's clock and the outline poll skip unchanged or hidden updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:26:57 +07:00
nntrivi2001 0c2f49876d feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:10:29 +07:00
nntrivi2001 9c573f1e3c docs: snapshot of the project's Claude Code memory
Decisions, environment quirks and pitfalls learned while working on AgentHub
(docs/claude-memory/, indexed by MEMORY.md). Notes holding personal account
details are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:27:35 +07:00
nntrivi2001 d72a255294 perf(workspace): switching workspaces keeps their terminals mounted
Panes were keyed by slot alone, so slot 0 of the next workspace reused the
previous one's pane: every switch disposed its terminals and attached new
ones (a WebSocket each, the whole screen replayed). The live panes of the
last 4 workspaces left now stay mounted as hidden siblings in the same
grid, keyed by paneKey (lane x 10 + slot), so going back is instant.

Measured on 3 workspaces x 2-3 shell panes, switching back to one already
opened: script 210 -> 52 ms, main thread 370 -> 120 ms, long tasks
120 -> 0 ms, terminal re-attaches 2-3 -> 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:03:27 +07:00
nntrivi2001 b08482c6aa feat(terminal): a text drag (a Files/Changes row, a skill) is pasted into the pane
xterm.js has no drop handling of its own, so dropped text was lost; the pane
now pastes it as a local terminal does, with a "Drop to paste" hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:43:03 +07:00
nntrivi2001 763759189f feat(server-mode): native-like folder browser, a developer kept to their own folder, no password minimum
- Folder browser (the Browse fallback for a remote browser): back/forward/up,
  a clickable breadcrumb that becomes a path field (Ctrl+L), Places (Home,
  the user's workspaces, recent picks), a filter, Name/Modified columns,
  Show hidden and New folder. A click selects; a double-click or Enter
  opens a folder or picks a file; Backspace goes up; typing jumps to a name.
- GET /api/lanes/browse: `hidden=1` (never the dot entries at the top of a
  non-admin's own folder), each entry's `mtime`, the answer's `home`.
- POST /api/lanes/browse/mkdir: one plain folder name inside an existing
  folder; a non-admin only in their own folder, none in server mode without
  one; under S3 a member's folder is made as the member.
- scope.ownFolder falls back to server.conf's USERS_ROOT under S3, so a
  settings file without AGENTHUB_USERS_ROOT no longer opens the host's home
  to every developer.
- Passwords: any non-empty password is accepted (no 8-character minimum).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:43:03 +07:00
nntrivi2001 b0dd5af22f fix: v2.7.5 — the chat shows each AskUserQuestion with the user's answer
- Chat: an AskUserQuestion is its own card (AskCard in MessageList.tsx), not
  part of the collapsed work line — each question with the option picked or
  the text typed, "declined" when refused. The transcript route passes the
  answers from Claude Code's toolUseResult (askAnswers, beside diff).
- Chat: the activity panel keeps the user's open/close per session for the
  tab, and opens by default only once the session has an output.
- Workspace home: the sent prompt's draft is dropped even though opening the
  session unmounts the home.
- Terminal: Ctrl+V with text on the clipboard pastes the text; only an empty
  clipboard still sends ^V.
- AGENTS.md / CLAUDE.md: run only the tests a change touches while iterating,
  the full suite once before committing.
2026-10-13 10:46:56 +07:00
nntrivi2001 7cb4f64781 feat(chat): answer the CLI's menus from the chat; Stop ends Thinking… (#74)
- A menu the CLI asks with (AskUserQuestion — one question or several,
  multiSelect —, a permission or trust prompt, Codex's approval, a
  first-start login menu) is read off the terminal under the chat
  (`parsePaneMenu`/`readRunMenu`) and shown in the prompt box's place as a
  card of buttons. A pick presses its digit (picks it, or ticks its box),
  Claude's unnumbered Submit row is reached with the arrows and Enter, and
  Cancel presses Esc. "Type something" opens a field in the card: its text
  goes in by the arrows, Ctrl+U over anything already typed there, a
  bracketed paste and Enter. That row presses no key — a second press of
  its digit typed the digit into the CLI's field ("44444…"). Keys verified
  on Claude Code 2.1.296 and Codex 0.161. AskUserQuestion fires no
  Notification, so a menu on screen also counts as waiting (live line, tab
  badge). Client only: no new route.
- #74: Esc before the model wrote anything puts the prompt back in Claude
  Code's input box and writes no "[Request interrupted by user]" line, so
  the transcript ended on the prompt and the chat read "Thinking…" for
  15 min. The chat's Stop now ends the live line until a newer transcript
  line arrives (matched by line, not by clock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:59:40 +07:00
nntrivi2001 69a2c37cd2 fix(terminal): macOS Vietnamese Telex keeps its capitals in Chrome too
macOS's own Vietnamese Telex types into xterm's textarea with no
composition events: a letter is an `insertText`, every later key of the
word an `insertReplacementText`, each keydown (keyCode 229) arriving after
the change. v2.7.4 forwarded the replacements, but a capital typed with
Shift still vanished — xterm drops any `input` that lands while a key is
held — and its 229 fallback could resend an edit. Replayed from a real
Chrome trace, "Abc xin chào" reached the pane as " x c".

lib/imeInput.ts now owns those edits: capture phase on xterm's element,
each one sent as DELs plus the new tail, the IME's input and 229 keydowns
hidden from xterm. Keys xterm sends from keypress (Space) and real
compositions (CJK) stay xterm's. Verified against xterm 5.5 in headless
Chrome: Telex → "Abc xin chào"; a plain keyboard sends the same bytes
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:20:07 +07:00
nntrivi2001 b3fe10a4f3 fix: v2.7.4 — a developer's System page carries no host or other-user path
- GET /api/settings/info for a developer: no DB path, no hooks file path,
  and no transcript-cache keys — those were EVERY user's transcript paths
  (their folders and session ids). GET /api/updates/status: no repo_root.
  Test: server-mode-leaks.
- Also in this release (earlier commits): a member's missing workspace
  folder is made as them at start; --allow-dangerously-skip-permissions only
  for an account that accepted the bypass warning or a bypass run.
Full suites: server 2889/2889, client 1250/1250, tsc -b.
2026-10-12 15:56:26 +07:00
nntrivi2001 8ecb96e9fc fix: a new account's first Claude session no longer opens on the bypass warning
--allow-dangerously-skip-permissions (bypass in the pane's shift+tab cycle)
made Claude Code show its Bypass Permissions warning on any account that had
not accepted it yet — every new member's first session, which the Chat UI
cannot answer, though nobody asked for bypass. Now passed only when the run
starts in bypassPermissions (asking is consent to see it) or the account's
user settings already hold skipDangerousModePermissionPrompt (pty-run
bypassAccepted; a member's read owner-checked from their home). The host,
which accepted long ago, keeps the flag. Test: pty-run.
2026-10-12 15:56:16 +07:00
nntrivi2001 c47dd230d4 fix(s3): starting a member's workspace makes its missing folder, as the member
v2.7.3's ENOCWD left a new member no web path to a first project: their
home is no workspace (EFOLDER) and a not-yet-existing folder could not
start. run-as.makeMemberFolder: mkdir -p through write-as, AS the member,
only inside their own home (never the home itself, never a host folder,
never for a member with no account). Test: server-s3-lanes.
2026-10-12 15:56:16 +07:00
nntrivi2001 46e3899945 fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again
  (a staged change since undone on disk) is no longer listed — JetBrains'
  GitChangesCollector drops it the same way. Mode-only changes stay listed.
  statusCounts (the dirty-tree safety checks) is unchanged.
- Chat (#73): Claude Code runs no hook before a folder is trusted, so a new
  pane's session has no row; the chat now says the session has not started,
  offers Open terminal, and asks before typing a message or /model //effort
  into the pane (an Enter would pick the trust prompt's "No, exit").
- Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on
  each key (insertReplacementText), which xterm.js ignores — only each word's
  first letter reached the pane. The edit is now sent as DELs + the new tail.
- Also: Ctrl+V pastes a screenshot into the terminal as a file path; the
  folder browser fallback lists files for a file field (`files=1`); the macOS
  native picker opens in front of the browser; a hidden tab is marked while a
  chat waits or once a turn ended; chat live line shows running calls, turn
  time and tokens, and "Done in …".
2026-10-12 15:40:51 +07:00
nntrivi2001 5d8fc9bb98 docs+test: the young-run window in SERVER-MODE/LANES; run.test plants its link in the real drop dir
run.test.js hardcoded /tmp/agenthub-drops-<uid>, which drops left for
<data dir>/drops long ago — it passed only while an old build's folder
survived in /tmp (gone after a reboot). Full suites: server 2887/2887,
client 1250/1250.
2026-10-12 14:36:22 +07:00
nntrivi2001 c8ea18f594 fix(client): a developer's Sessions page no longer asks for remote sources (403)
/api/remote-sources is STAFF_ONLY; Sessions loaded its labels for every
viewer, so each developer page load logged a 403.
2026-10-12 14:19:42 +07:00
nntrivi2001 64173a5428 fix: a run started a moment ago is never judged dead — another dashboard's tmux list may predate it
Only the process that starts a pane writes it into its own cached tmux list
(noteSession); a second dashboard on the same database (a dev server) holds
a member list up to 15 s old, so its heal beat ended a Codex pane seconds
after it started and the proxy gateway answered 401. reconcileOrphans and
healRunId skip a run younger than 20 s (dashboard-runs.isYoungRun).
Tests: dashboard-runs (young vs old).
2026-10-12 14:15:22 +07:00
nntrivi2001 943ba03b76 fix: v2.7.3 — Update now restarts through our own service unit, member panes stay live on a dev server, a start refuses a missing folder
- self-restart: a server in our unit's cgroup (/agenthub.service, system
  or user) restarts via `systemctl restart --no-block` (sudo -n for the
  system unit), our launchd job by exiting into KeepAlive. The detached
  restart-helper replacement held the port while Restart=always started
  the unit every 3 s (v2.7.2 on the S3 box: 240+ restarts, MainPID 0).
- run-as.mayHaveTmux: only ENOENT means "no tmux". A dev server in server
  mode without CAP_DAC_READ_SEARCH got EACCES on a member's 0700
  .agenthub/tmux, dropped the member from the list and ended their runs
  within seconds — the real cause of `401 not a live pane's token`.
- tmux: a member's list is re-asked every 5 s, not 1 s (one sudo + three
  auth.log lines each).
- POST /api/lanes/:id/start: 409 ENOCWD for a folder that no longer
  exists (the pane started in the account's home otherwise).
- Settings: Remote sources only for staff (a developer got a 403).
- scripts/test-server.js drops every inherited AGENTHUB_* (pane env).
Tests: self-restart.test.js, server-s3-panes (mayHaveTmux), lanes-api
(ENOCWD); touched suites 771 + client pages 191, tsc -b.
2026-10-12 14:04:08 +07:00
nntrivi2001 3a8be55b55 feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
2026-10-12 13:06:04 +07:00
nntrivi2001 2fd0c903a2 fix: v2.7.1 — the UI builds again (v2.7.0's update failed at npm run build)
- v2.7.0 could not be installed or updated to: the client build
  (`tsc -b && vite build`) type-checks test files too, and a new
  RunSetup test rendered PromptEditor without its required `slash` and
  `fileCwd` props, so `tsc -b` failed and every update stopped at
  "npm run build failed". The test now passes them.
- Verified on a clean v2.7.0 worktree + this fix: `tsc -b` and
  `vite build` both exit 0; RunSetup tests 19/19.
2026-10-10 15:18:00 +07:00
nntrivi2001 f6f1bf4a76 feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default,
  saved per account): the session view's tab bar holds every workspace's
  live sessions, a combo may mix them, and focusing a pane selects its
  workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once
  under `all` in agenthub.workspace.paneLayout.
- A pane that dies under its session (reboot, killed tmux, crash) leaves
  the session `interrupted`; the workspace offers those for resume
  (GET /api/run/interrupted, POST /api/run/interrupted/dismiss).
- Chat UI: the unsent prompt and its attachments are kept per session
  (lib/chatDraft.ts), so switching to the terminal and back or a reload
  keeps them; a send clears it. Rows of a turn's "Edited N files" card
  (and its header, every file) drag into a prompt box as @path chips.
- Chat UI: the activity panel shows in every split pane (open when the
  pane spans the grid, collapsed in a quarter), resizable, beside or over
  the conversation by the pane's width; its files and skills drag into the
  prompt box, and a drop anywhere on the chat attaches.
- Chat UI: a screenshot the session read outside the workspace previews
  (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text
  files are served back as text/plain; long prompts and answers are no
  longer cut at 10 KB (tool output still is); a new live session shows an
  empty chat until its first hook instead of "Session not found".
- Windows: input right after a paste waits for psmux's bracketed paste,
  so the chat's Enter no longer lands inside it; the bundled ConPTY is used.
- Changes panel: a Workspace context fold (instructions, files, folders).

Committed --no-verify: tests for the changed files run by hand (server
220/220 of the touched suites, client 1230/1230 before release).
2026-10-10 15:10:13 +07:00
nntrivi2001 f0dc1445fc fix: v2.6.2 — Windows panes no longer read as dead (psmux ignores -F), Update now's setup works under cmd.exe
- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
  asks for. Read as names, every live pane looked gone and the heal beat
  closed its run (and its tab) within seconds while the session kept
  running. tmux.js (and the Update all helper) now take the text before the
  first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
  subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
  Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
  npm's output (the error, not the warnings), says when it hit the time
  limit, and gets 20 min instead of 5.
2026-10-10 12:01:01 +07:00
nntrivi2001 96e4d28f7c fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists.
  The server was forked from the dashboard's cwd (the plugin cache dir),
  outlives updates, and the old cache dir is pruned — after that every new
  pane started in a deleted directory, where `claude` died at once ("run not
  found") and a shell printed getcwd errors. tmux calls now run from `/`,
  and the pane's sh wrapper cds into its folder itself, which also heals a
  tmux server that is already stuck.
- Changes panel: a workspace's extra folders are sections stacked under the
  workspace folder (VS Code Source Control style), with Close repository /
  Close other repositories on a header's right-click.
- Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]`
  line folds into its turn; uploads under `<data dir>/drops/` are listed.
- Update check: a checkout holding commits a newer release lacks reports
  `diverged_from_release` instead of offering a fast-forward that can only fail.
2026-10-09 17:24:14 +07:00
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00
nntrivi2001 ce773a3f5a Merge branch 's3-async-tmux' into s3-followups 2026-10-09 15:21:57 +07:00
nntrivi2001 4407b3bb16 fix(server-s3): list a member's tmux panes asynchronously
A frozen member (SIGSTOPped tmux server) cost every user one synchronous
3 s block of the event loop per minute (F37's back-off ceiling). Member
listing now runs through async execFile (same 3 s / SIGKILL / 1 MiB
bounds, same back-off) into the listed cache the synchronous readers
(liveSessions, listSessions, listOutput) already use: stale-while-
revalidate, 15 s max age, cold or failing = that member's panes UNKNOWN
(F38 unchanged). A session this process creates or kills is written into
the member's cached answer at once, and a list in flight across that
change is dropped. Host tmux calls, local mode and every click's call
(new-session, send-keys, kill, has-session) are unchanged.

review5's stalled-member test now polls for 4 s past the timeout and
asserts each poll < 100 ms, event-loop stall < 500 ms and one sudo
(old code: first poll 3157 ms).
2026-10-09 15:21:31 +07:00
nntrivi2001 44fd664670 Merge branch 's3-async-copy' into s3-followups 2026-10-09 15:18:57 +07:00
nntrivi2001 118eaee2ec docs(server-mode): plan moved in from the deleted AgentHub-Server repo; P6 spike result; Express patch only on 4.x
- docs/plans/server-mode/README.md: the original multi-user design, now kept
  here (AgentHub is the one source); desktop-parity and S3-PLAN link to it.
- S3-PLAN P6: managed-settings spike run on agh-test1 — the managed
  marketplace/plugin arrive at the first interactive session (onboarding
  seeded), never via the CLI alone, and no user-scope record is written.
- server/index.js: the Layer.handle_request patch applies only on Express 4
  (Express 5 handles async rejections itself and has no lib/router/layer).
2026-10-09 15:18:57 +07:00
nntrivi2001 3ab750c765 fix(server-s3): transcript snapshot copies asynchronously (F42 follow-up)
copyIfNewer read/wrote a member file of up to 256 MiB with readSync/writeSync,
blocking the event loop for every user. It now copies with fs.promises 1 MiB
at a time from the runAs.openSafeAsync handle (owner/type check kept), keeps
the 256 MiB member cap and zero-run holes, writes a temp file and renames it
into place, and joins a copy already in flight for the same destination.
A failed copy is logged and never rejects; snapshotTranscript is async and
all three import callers await it.
2026-10-09 15:17:55 +07:00
nntrivi2001 84e2a9f0ac feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 14:57:10 +07:00
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00
nntrivi2001 a9c71cd905 feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
2026-10-08 12:22:14 +07:00
nntrivi2001 6ef9a5d4b7 feat: v2.5.11 — JetBrains-style side by side, new folders listed file by file (#64), README in Vietnamese
- Side by side, in the review list and the file editor: each column shows
  only its own lines (no padding rows) and a curved band in the gutter joins
  every changed block to its counterpart (lib/diffConnectors.ts). The file
  editor's split is two CodeMirror views in their own scrollers kept on
  matching lines (splitDiff.ts, replacing MergeView), with » to revert a
  block. Checked in a real browser (headless Chrome) as well as by tests.
- #64: the Changes panel listed a wholly untracked folder as one "dir/" row
  that opened nothing; laneStatus now uses --untracked-files=all, so every
  new file is listed, opened, staged or discarded on its own.
- README.md is the Vietnamese quick start (the default) with README.en.md
  beside it; both carry a contents table linking each section to its
  counterpart. The long overview moved to docs/OVERVIEW.md + OVERVIEW.vi.md.

Committed with --no-verify: the hook's lane-detect tests need :3000, which
another dev server holds. Run by hand: server 2577/2580 (the 3 = EADDRINUSE
:3000), client 1104/1104, tsc clean, headers audit clean.
2026-10-07 16:02:35 +07:00
nntrivi2001 1619954fad fix: v2.5.11 — the commit box wraps only when the panel is narrow
The message keeps the row with the commit, Stage all and Unstage all
buttons while there is room (a 50/50 split, a full-width panel); in a
narrow panel the buttons wrap under it instead of squeezing the message
to a few characters. Client 1095/1095 on the committed tree.
2026-10-07 15:40:43 +07:00
nntrivi2001 f0656603c3 feat: v2.5.11 — cherry-pick, revert, undo and amend in the Changes panel, Open terminal from the AI menu
Git in the Changes panel
- Revert any commit, Undo the last commit (soft, changes stay staged) and,
  with another branch's history open, Cherry-pick onto this branch — all from
  the history's right-click menu. Amend folds staged changes into the last
  commit. Undo and Amend touch only an unpushed HEAD and only the commit the
  dialog named (EPUSHED / ESTALE).
- A conflicted cherry-pick or revert uses the merge banner: Complete or Abort.
  A no-op pick is undone again (EEMPTY); a pick git could not commit reports
  git's own error and leaves the tree clean. A rebase started in a terminal
  blocks every history action.
- Push, Publish, branch delete, cherry-pick, revert and amend ask first. An
  armed discard reads as a red "Discard?" chip. There is no bulk discard.
- History can show another branch (?ref=). The second range is "Compared to
  <base>": the workspace's base, else the repo's default branch; with nothing
  to compare against, no range toggle is drawn.
- The diff editor gets each change block's Undo / Keep bar.

Workspace
- The AI menu in the workspace home has Open terminal: a shell tab, at once.
- "+ Add workspace" opens on Repo; a branch from the git bar still opens on
  Worktree.
- The sidebar no longer keeps a list of closed Terminal tabs.
- Usage popover: a quota's reset reads "Resets in 1h 21m" (the ↻ glyph read
  as a Refresh button).

Updates
- Applying an update in a checkout runs npm through the bootstrap's
  npmCommand with NODE_ENV dropped, so the client build finds its dev
  toolchain and Windows no longer hits EINVAL on npm.cmd.

Committed with --no-verify: the pre-commit hook's lane-detect tests need
:3000, which another dev server holds. Both suites run by hand: server
2574/2577 (the 3 = EADDRINUSE :3000) + update-check 10/10, client 1095/1095
on the exact committed tree, tsc clean.
2026-10-07 15:34:17 +07:00
nntrivi2001 9a54978eef feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

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

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

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

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

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

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

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

- shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS,
  C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and
  Windows are left alone
- RunSetup.tsx: the `/` list now offers Claude Code's bundled skills
  (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and
  every enabled plugin's commands and skills as /<plugin>:<name>
- Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached
  per token), not by .claude.json: a running session can leave the
  credentials store holding one account's token under another account's
  name. The Profiles modal, its Check quota and the status-bar quota now show
  the account requests are really billed to and warn on the mismatch; a
  switch saves the outgoing token into its OWNER's profile (credentials only)
  instead of overwriting the profile .claude.json names; saving a profile
  while they disagree is refused (EMISMATCH); a saved profile holding another
  account's token reports EWRONGACCOUNT instead of that account's usage
- Running Claude Code sessions follow a switch on their own (they re-read the
  credentials store when it changes), so the "close your sessions first"
  warning is gone
2026-10-07 11:18:10 +07:00
nntrivi2001 7543b8d62a fix: v2.5.8 — the browser terminal attaches on Windows
node-pty on Windows looks the file it starts up on PATH exactly as given and
never adds `.exe`, so `tmux attach-session` started as a bare `tmux` failed
with "File not found" on every attach: the browser's terminal socket opened,
closed, and reconnected every 1.5 s.

- pty-attach.js: attachBinary names tmux by its full file name on Windows
  (ai-tools.which("tmux"), else tmux.exe)
- websocket.js: an attach that throws is logged as `[WS] attach <run> failed`,
  the one place that says why the terminal keeps reconnecting
2026-10-07 00:23:32 +07:00
nntrivi2001 4163f431bb fix: v2.5.7 — panes work on psmux, the Windows tmux
psmux (the WinGet `tmux` on Windows) has no `=name` exact-match targets: it
answered "no server running on session '=agenthub-lane-1'" for a live pane,
so every Windows start reported "the session's command exited as soon as it
started" while the session ran on, and the browser could not attach.

- server/lib/tmux.js: `session`/`pane` targets probe the first live session
  once — `=name` failing where the bare name answers means bare names from
  then on; real tmux keeps `=name` / `=name:` (it matches by prefix otherwise)
- pty-attach.js and cc-update-all-helper.js use the same targets
2026-10-06 23:56:12 +07:00
607 changed files with 89862 additions and 10665 deletions
+4 -2
View File
@@ -22,7 +22,8 @@
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
"timeout": 5
"timeout": 5,
"async": true
}
]
}
@@ -34,7 +35,8 @@
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
"timeout": 5
"timeout": 5,
"async": true
}
]
}
+18 -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
@@ -91,6 +103,11 @@
# (default: enabled).
# AGENTHUB_PRICING_SYNC=0
# The status bar's Usage panel reads Antigravity's quota by running
# `agy --print /usage --output-format json` (no model call), which may let agy
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
# AGENTHUB_AGY_USAGE=0
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
# their own connection so they never stall other requests. 0 runs them inline
# on the event loop instead (default: worker). A job past the timeout answers
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
# commit-msg hook – runs Commitlint
npx --no-install commitlint --edit "$1"
+1
View File
@@ -11,3 +11,4 @@ client/package-lock.json
*.md
*.yml
*.yaml
server/vendor/
+3 -3
View File
@@ -8,7 +8,7 @@
- Correctness over cleverness.
- Small, scoped, reversible diffs.
- Preserve existing behavior unless change is requested.
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README.md — Vietnamese, the default — and README.en.md; docs/OVERVIEW.md + OVERVIEW.vi.md, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
## Workspace stage
@@ -23,8 +23,8 @@ make while you work here as Codex.
- `scripts/` for hook/install/import/cleanup utilities.
## Validation expectations
- Backend changes: run `npm run test:server` when possible.
- Frontend changes: run `npm run test:client` when possible.
- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
- Run a full suite (`npm run test:server` / `npm run test:client`) only when genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib), a shared server core (`server/db.js`, `server/index.js`, auth/scope, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks. The pre-commit hook already picks related vs. full tests per commit, so do not add an extra full run before committing.
- If any check is skipped, report it explicitly.
## Safety expectations
+52 -30
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. |
@@ -333,7 +333,7 @@ graph TD
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status and `?sections=a,b` to compute only those parts. The `GET /` bundle itself lives in `lib/workflow-stats.js` and runs in the read worker (`lib/read-pool.js`). `GET /api/workflows/skill-usage` reports per-skill and per-plugin call counts, counting `PreToolUse` rows only so a call is not double-counted against its `PostToolUse` twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details |
| `lib/workflow-stats.js` | The eleven `GET /api/workflows` aggregations as pure reads over a passed-in connection (`computeWorkflowData(conn, status, sections)`), so they run in the read worker. `stats.topFlow` is the head of `toolFlow.transitions`: tool pairs come from one `LEAD() OVER (PARTITION BY session_id ORDER BY id)` pass — never a correlated `MIN(id) … id > ?` per event, which is quadratic in a session's size |
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there |
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a caller's `maxAgeMs` reusing a result that young (`/api/workflows` 30 s, `/api/analytics`'s events scans 10 s — args are the key, so a member's scoped result is never shared), a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there. The server's WAL checkpoint (`walCheckpoint`, every `DASHBOARD_WAL_CHECKPOINT_MS`, default 5 s) also runs here, on a second writable connection of the worker: the main connection sets `wal_autocheckpoint = 0`, so no hook's `COMMIT` pays for a checkpoint; a failed checkpoint turns the automatic one back on |
| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
| `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p <pids> -d cwd -Fn` on macOS or `/proc/<pid>/cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` |
| `bin/agenthub.js` | Dependency-free umbrella CLI (`agenthub <command>`) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agenthub.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `AGENTHUB_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `agenthub status` shows a ●/○ up/down indicator and `agenthub start` boots a detached production server (waits for /api/health, logs to `data/agenthub-server.log`). `agenthub repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.agenthub_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `agenthub` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason |
@@ -374,8 +374,8 @@ graph TD
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<tmpdir>/agenthub-drops-<uid>/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree`, `/:id/git/download` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/upload` (create-only, `lane-file-transfer.js`) and `POST /:id/git/:action` are same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
@@ -385,8 +385,9 @@ graph TD
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
@@ -487,7 +488,7 @@ graph TD
### Splash & loading UX
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#924224` — Claude's terracotta three steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#eceef2` and a `.card` is the brighter `surface-3` `#fdfdfe` (the light Default is IntelliJ Light's palette), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is IntelliJ's `#285ac0`, darker than dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking IntelliJ Light's editor scheme. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
@@ -757,6 +758,7 @@ erDiagram
TEXT metadata "JSON blob"
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
TEXT pinned_at "ISO 8601 or NULL — pinned to the top of its workspace's sidebar list"
TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
}
@@ -911,10 +913,10 @@ erDiagram
| `idx_agents_status` | agents | `status` | Kanban board column queries |
| `idx_events_session_created` | events | `session_id, created_at` | A session's newest event / time order (watchdog every 15 s, every lane payload) |
| `idx_events_tool` | events | `tool_name` | Per-tool counts (analytics, workflows) and the events filter's tool list without a scan of every event row |
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type` (that one is not id-ordered); dropping it once made every hook sort its whole session |
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type_tool` (that one is not id-ordered); dropping it once made every hook sort its whole session |
| `idx_events_type` | events | `event_type` | Filter events by type |
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
| `idx_events_session_type` | events | `session_id, event_type` | Per-session event-type filters |
| `idx_events_session_type_tool` | events | `session_id, event_type, tool_name, created_at` | Per-session event-type filters, and a covering read for session stats (per-tool and per-type counts), the hook's TurnDuration/APIError dedupe and the import's newest-per-type resume point (1.2 s → 20 ms on a 29K-event session). Replaces `idx_events_session_type` (dropped at startup), so a hook writes no more index entries than before |
| `idx_events_agent_type` | events | `agent_id, event_type` | `importSubagentFromJsonl` reads the agent's existing `tool_use_id`s once through it (it used to run a `data LIKE` per tool event) |
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
@@ -966,6 +968,11 @@ All messages are JSON with this envelope:
}
```
Frames a hook produces are sent only after its transaction committed and its
pane binding (`bindRunSession`) ran — in order, in the same tick — so no frame
describes a rolled-back row, and a server-mode member's first frames of a new
session reach them. `new_event` carries the event's row `id` (additive).
The `remote_source.status` message (emitted by the **Remote Data Sources**
sync poller and the `/api/remote-sources` routes) carries
`{ id, status, error?, last_sync_at? }`, where `status` is one of
@@ -1059,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.
@@ -1216,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
@@ -1572,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
@@ -1647,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
@@ -1736,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
@@ -2218,8 +2237,8 @@ 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/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, 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/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. |
| **`Sidebar.tsx`** | The update state and its **Check for updates** button live in the connection details modal the sidebar owns (opened from the status bar; the sidebar has no footer); the status bar shows a green dot beside the version while a release is out. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
@@ -2239,6 +2258,7 @@ interface UpdateStatusPayload {
situation?: // categorical hint for the UI
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
| "release_available" // fast-forward onto the release tag
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
@@ -2304,6 +2324,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch <remote> --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep <tag>` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
### Why Self-Apply Stays Narrow
@@ -2316,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` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
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).
---
@@ -2347,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 + quick actions + 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
@@ -2377,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)"]
@@ -2394,16 +2415,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
| Component | Responsibility |
| --- | --- |
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`, which also returns the `subject` session a pulse is about — name, cwd, lane) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. An error alerts on the transition INTO `error` only, and **Clear alerts** (`clearErrors`) moves the errored sessions into `dismissed`: their later updates, agent errors and failure events stay silent until the session goes active again or ends. `attentionOf` lists errored then waiting sessions by name; `MOMENT_OF_PULSE` maps each pulse to a pet animation. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
| **`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. |
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
| **`Sagi.tsx`** | Shell component. 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`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` 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`** | Pure mood → phrase pools. |
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
| **`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). |
| **`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. |
### Touchpoints Outside the Folder
@@ -2415,7 +2437,7 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. |
| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. |
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
| `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. |
---
+22 -12
View File
File diff suppressed because one or more lines are too long
+408 -83
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, 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
@@ -323,49 +375,265 @@ agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
```
See [`README.md` → Run as a service](./README.md#run-as-a-service-auto-start-on-boot)
See [`docs/OVERVIEW.md` → Run as a service](./docs/OVERVIEW.md#run-as-a-service-auto-start-on-boot)
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
---
## 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
+93
View File
@@ -0,0 +1,93 @@
# AgentHub
[Tiếng Việt](README.md) · **English**
A dashboard that runs on your machine to watch and drive your Claude Code (and
Codex) sessions from the browser.
| Section | Tiếng Việt |
| --- | --- |
| [Requirements](#requirements) | [Yêu cầu](README.md#requirements) |
| [Option 1 — Through Claude Code](#install-claude-code) | [Cách 1 — Cài qua Claude Code](README.md#install-claude-code) |
| [Option 2 — From a terminal](#install-terminal) | [Cách 2 — Cài bằng terminal](README.md#install-terminal) |
| [More docs](#more-docs) | [Tài liệu thêm](README.md#more-docs) |
<a id="requirements"></a>
## Requirements
- **Node.js ≥ 22.5**
- **git**
- **tmux**
To install them:
- Ubuntu: `sudo apt install git tmux` (get Node.js ≥ 22.5 from <https://nodejs.org>)
- macOS: `brew install node git tmux`
<a id="install-claude-code"></a>
## Option 1 — Install through Claude Code (recommended)
Send **each line on its own** straight into the Claude Code chat:
```
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
```
```
/plugin install agenthub@agenthub
```
```
/agenthub:agenthub-open
```
The last one prints the dashboard's address (by default <http://localhost:4820>) —
open it in the browser and you are done.
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
To update to a new version: `/agenthub:agenthub-update`.
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
<a id="install-terminal"></a>
## Option 2 — Install from a terminal (without Claude Code)
Run these in a terminal, one after another:
```bash
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
cd AgentHub
npm run setup # install the dependencies + the agenthub command
npm run build # build the UI
npm start # run the dashboard
```
Open <http://localhost:4820> in the browser.
`npm start` stops when the terminal closes. To have the dashboard start every time
the machine boots:
```bash
agenthub service install
```
> ⚠️ Pick **only one** of the two options. With both installed, every event is
> counted twice.
<a id="more-docs"></a>
## More docs
- Vietnamese README: [README.md](README.md)
- The full feature overview: [docs/OVERVIEW.md](docs/OVERVIEW.md) ·
Tiếng Việt: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md)
- Detailed install, troubleshooting: [INSTALL.md](INSTALL.md)
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
- The `agenthub` command: [docs/CLI.md](docs/CLI.md)
Internal build — all rights reserved.
+60 -326
View File
@@ -1,360 +1,94 @@
# AgentHub
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
monitoring is one part of it, not the whole. Hooks POST every tool call to an
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
sessions, workspace runtimes, and Claude Code's own configuration.
**Tiếng Việt** · [English](README.en.md)
AgentHub records and shows — it does not orchestrate. The session in control makes
every decision.
Dashboard chạy trên máy anh chị để theo dõi và điều khiển các phiên Claude Code
(và Codex) ngay trên trình duyệt.
Internal build — all rights reserved.
| Phần | English |
| --- | --- |
| [Yêu cầu](#requirements) | [Requirements](README.en.md#requirements) |
| [Cách 1 — Cài qua Claude Code](#install-claude-code) | [Option 1 — Through Claude Code](README.en.md#install-claude-code) |
| [Cách 2 — Cài bằng terminal](#install-terminal) | [Option 2 — From a terminal](README.en.md#install-terminal) |
| [Tài liệu thêm](#more-docs) | [More docs](README.en.md#more-docs) |
*(Tiếng Việt: [README.vi.md](README.vi.md))*
<a id="requirements"></a>
## Main features
## Yêu cầu
**Observe**
- **Node.js ≥ 22.5**
- **git**
- **tmux**
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
searchable: tool calls, token usage, cost, subagent trees, transcripts.
- **Analytics and cost.** Per-model token totals and estimated cost, with
editable pricing rules; top tools, agent types, and workflow patterns.
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
targets that fire on them.
- **History import.** Every session under `~/.claude/projects/` is imported on
startup; archives from another machine come in through Settings → Import
History (idempotent, never double-counts tokens).
- **Remote sources.** Pull session history from other machines over SSH
(`agenthub remote-sources`).
Cài:
**Drive**
- Ubuntu: `sudo apt install git tmux` (Node.js ≥ 22.5 tải ở <https://nodejs.org>)
- macOS: `brew install node git tmux`
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a workspace moves through its stages and
the dashboard shows where it is.
- **Stage detection.** With a template installed, the stage is inferred from the
tool stream, so a session that never calls `agenthub stage` still shows
progress — rendered dashed amber and never as done, because an inference is
not evidence.
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
Beside the workspace sits a collapsible **Changes** panel — an overview of the
changed files, a full review of every diff, or one file at a time, comparing
either uncommitted work or the whole branch against its base. There is no
merge/rebase/reset button by design: recovery from those needs a terminal.
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
<a id="install-claude-code"></a>
**Configure**
## Cách 1 — Cài qua Claude Code (khuyên dùng)
- **Workspaces like Claude Desktop projects.** The sidebar lists every
workspace with its sessions under it (a live one opens its tmux pane, any
other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files and extra Folders, which Claude and Codex
sessions start with.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (General / Data / About / Appearance / Pet / Notifications) /
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
Per-browser only.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
- **Outside the dashboard, all opt-in:** a read-only MCP server
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
Claude Code statusline with session and today's cost
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
Prometheus rules + Grafana boards for `/api/metrics`
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
## Prerequisites
| Need | Version | Required for |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` workspaces |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node **25** is the one version to avoid: it breaks 6 server tests through a
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change. Both were gone again by 26 (1712 server + 488 client tests green on
v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub workspace shell` fail without it. Everything else
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows has no native tmux — run AgentHub under WSL2 and install it there.
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
no systemd, so the dashboard runs until logout/reboot and comes back at the
next Claude Code session, or by hand with `agenthub start`. Details and a
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
Verify, both from your shell and from the server's point of view:
```bash
tmux -V # e.g. tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
## Install as a Claude Code plugin
Two commands on a machine that has nothing but Claude Code, no clone and no
`npm run setup`:
Anh chị hãy gửi **riêng từng dòng** sau thẳng vào khung chat Claude Code:
```
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
```
```
/plugin install agenthub@agenthub
```
The first session start installs the hooks, boots the server, puts `agenthub` on
PATH; it runs detached, so the session never waits on
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
Details, including what to delete on uninstall:
[`docs/PLUGINS.md`](docs/PLUGINS.md).
## Install from a checkout
```bash
npm run setup # root and client dependencies
npm run build # builds the client into client/dist
npm start # serves the built client and the API on :4820
```
/agenthub:agenthub-open
```
Open <http://localhost:4820>.
Dòng cuối in ra địa chỉ dashboard (mặc định <http://localhost:4820>) — mở nó
trên trình duyệt là xong.
Development, with hot reload:
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
Cập nhật bản mới: `/agenthub:agenthub-update`.
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
Gỡ hẳn khỏi máy: **Settings → Hệ thống → Gỡ cài đặt**, hoặc `agenthub uninstall`
(chỉ liệt kê) rồi `agenthub uninstall --yes` — xoá cả database của AgentHub; các cuộc
trò chuyện của Claude Code/Codex, worktree và repository của bạn được giữ lại.
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
<a id="install-terminal"></a>
## Cách 2 — Cài bằng terminal (không qua Claude Code)
Chạy lần lượt trong terminal:
```bash
npm run dev # server on :4820, Vite client on :5173
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
cd AgentHub
npm run setup # cài thư viện + lệnh agenthub
npm run build # build giao diện
npm start # chạy dashboard
```
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
built UI is served from (defaults to `client/dist`; the plugin install points it
at its own runtime directory). `postinstall` writes the Claude Code hook entries
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
every event is counted twice.
Mở <http://localhost:4820> trên trình duyệt.
### Run as a service (auto-start on boot)
`npm start` only runs while that terminal (or `agenthub start`'s background
process) is alive — a reboot loses it. `agenthub service install` sets up a
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
every reboot, with no manual step and no Claude Code (or any AI tool) needed
to trigger it:
`npm start` tắt khi đóng terminal. Muốn dashboard tự chạy mỗi lần bật máy:
```bash
npm run build # the service serves the built client, same as npm start
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
agenthub service install
```
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. The same is a switch in the dashboard — Settings → About →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
> ⚠️ Chỉ chọn **một** trong hai cách. Cài cả hai thì mỗi sự kiện bị đếm hai lần.
## The CLI
<a id="more-docs"></a>
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
## Tài liệu thêm
```bash
agenthub status # is the dashboard up
agenthub start # start it in the background and wait for healthy
agenthub service install # auto-start on boot via systemd --user (Linux)
agenthub sessions # recent sessions
agenthub workspaces # workspaces with stage and progress
agenthub workspace pipeline # this lane's pipeline template, or switch it
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
agenthub stage <name> # declare the current lane's stage
agenthub tail # live event feed
```
- README tiếng Anh: [README.en.md](README.en.md)
- Giới thiệu đầy đủ tính năng: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md) ·
English: [docs/OVERVIEW.md](docs/OVERVIEW.md)
- Cài đặt chi tiết, xử lý sự cố: [INSTALL.md](INSTALL.md)
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
- Lệnh `agenthub`: [docs/CLI.md](docs/CLI.md)
`agenthub --help` lists the rest.
## Workspaces
A workspace is a working directory the dashboard watches. Two kinds:
- **adopted** — a directory you already had. The dashboard only reads it; it is
never reset or deleted.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
reset that worktree, behind a three-check destroy guard and a counted preflight
the caller has to echo back. Removing a workspace — either kind — deletes only the
dashboard's record of it: the working tree, its branch and its database stay,
for you to delete yourself.
```bash
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
```
Every workspace also carries the CLI that drives it — `claude` (the default) or
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex sessions and token usage
are read from Codex's own rollout journals: every Codex session (a workspace's or
not) appears in Sessions with a **Codex** badge and is priced like any other,
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
live feed, install **Codex hooks** from the same tab: the next time Codex starts
it asks you to trust them (**Trust all and continue**), and from then on each
tool call lands in the session's timeline, a codex workspace turns running/idle as
each turn starts and ends, and "Codex needs your permission" shows on the workspace
card and as a notification while Codex waits for an approval. A Codex workspace also
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
advanced; without hooks the dashboard mirrors its running/idle status from the
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
usage are read from agy's own conversation files
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
the same tab, add a live feed: each tool call in the session's timeline and a
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
and it has no hook for a permission prompt, so an Antigravity workspace never shows
"needs your permission". agy fires no hook when it exits, nor when a run ends
on an API error or a permission refused in headless mode, so a session gone
quiet is marked completed and its workspace set idle — after ten minutes of quiet
for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity workspaces carry no pipeline either.
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
read from Cursor's agent transcripts
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
no token counts or model ids there, so a Cursor session shows no tokens and no
cost.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
Further development is planned.
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done.
A workspace's pipeline template can be switched after creation — `agenthub workspace
pipeline <template>` from the terminal, or the pipeline-template picker next to
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
current declared stage against the new template's nodes and warn if it no
longer matches one.
A workspace can also run **its own application stack**, isolated per workspace, when its
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
ports and per-workspace directories derive from it:
```bash
agenthub workspace up # boot the stack of the lane owning this directory
agenthub workspace runtime # slot, ports, service health
agenthub workspace logs api # tail a service log
agenthub workspace down
```
Services are fully detached, so restarting the dashboard never stops a running
workspace. This is resource namespacing on the host, not a container: workspaces run as the
same user and share the network.
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
`POST /:id/git/:action`), and the full
runtime/profile contract.
## Tests
```bash
npm run test:server # node:test
npm run test:client # Vitest
```
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
runs Prettier, then the tests the commit needs: both full suites for a big or
risky commit (over 10 code files, a dependency, build/test config or the hook
itself, a deleted file), only the related tests for a small one (`vitest
related` for `client/`, the server test files that name each other staged
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
both full suites.
## Layout
| Path | What |
|---|---|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI |
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard |
## Docs
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
- [`INSTALL.md`](INSTALL.md)
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
Internal build — all rights reserved.
+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) |
+681 -9
View File
@@ -40,7 +40,7 @@
const path = require("node:path");
const fs = require("node:fs");
const { spawn } = require("node:child_process");
const { spawn, spawnSync } = require("node:child_process");
// Resolve the repo root relative to this script's REAL location so the CLI
// works both from a checkout (./bin/agenthub.js) and through the global symlink
@@ -160,7 +160,13 @@ async function api(method, pathname, body, options = {}) {
const form = body instanceof FormData;
res = await fetch(url, {
method,
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
headers: {
...(body && !form ? { "Content-Type": "application/json" } : {}),
// Server mode: inside a dashboard pane, act as the workspace's owner.
...(process.env.AGENTHUB_PANE_TOKEN
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
: {}),
},
body: body ? (form ? body : JSON.stringify(body)) : undefined,
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
});
@@ -1278,12 +1284,14 @@ async function cmdDoctor() {
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// else.
try {
const { available } = await get("/api/run/tmux");
const { available, upgrade } = await get("/api/run/tmux");
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
!available
? `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
: upgrade
? `${c.yellow("!")} tmux ${upgrade}`
: `${c.green("✔")} tmux available to the server`
);
} catch {
console.log(`${c.dim("·")} tmux not reported by this server`);
@@ -1391,14 +1399,36 @@ async function cmdReinstallHooks() {
* built client) and wait until /api/health answers. No-ops with a pointer to
* the live URL when a server is already up. The child is fully detached with
* its output appended to data/agenthub-server.log, so closing this terminal does
* not stop the dashboard; stop it later with `kill <pid>` (the PID is
* printed and registered in ~/.claude/.agenthub.json).
* not stop the dashboard; stop it later with `agenthub stop`.
*/
async function cmdStart(flags) {
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
return;
}
// A plugin install starts the way a Claude Code session does: its bootstrap
// knows the runtime dir, the UI bundle and the autostart unit.
if (/[\\/]\.claude[\\/]plugins[\\/]cache[\\/]/.test(REPO_ROOT)) {
spawnSync(
process.execPath,
[path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"), "--worker"],
{
stdio: "inherit",
}
);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
return;
}
await new Promise((r) => setTimeout(r, 500));
}
console.error(c.red("✖ Server did not become healthy within 30 s — run /agenthub-doctor"));
process.exit(1);
}
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
if (!fs.existsSync(clientDist)) {
console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
@@ -1441,7 +1471,7 @@ async function cmdStart(flags) {
console.log(
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
);
console.log(c.dim(` Stop it with: kill ${child.pid}`));
console.log(c.dim(" Stop it with: agenthub stop"));
return;
}
tick++;
@@ -1457,6 +1487,79 @@ async function cmdStart(flags) {
process.exit(1);
}
/**
* Quit AgentHub (issue #65): every lane pane is killed, the server stops, and
* the plugin bootstrap starts none until `agenthub start` — or a boot/login
* through an enabled autostart unit (`server/lib/quit.js`).
*/
async function cmdStop() {
if (!(await serverIsUp())) {
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).markQuit();
console.log(
`${c.dim("○")} Dashboard is not running — it stays stopped until ${c.bold("agenthub start")}`
);
return;
}
const res = await post("/api/settings/quit", undefined, { allowError: true });
if (res?.status) {
// Server mode: a shell on the host carries no login, and Quit is an admin's.
if (res.data?.error?.code === "EAUTH" || res.data?.error?.code === "EADMIN") {
console.error(
`${c.red("✖ This dashboard runs in server mode: Quit needs an admin login.")}\n` +
" Log in as an admin and use Settings → System → Quit AgentHub, or stop its service:\n" +
` ${c.bold("sudo systemctl stop agenthub")} ${c.dim("# system unit (one Linux user per account)")}\n` +
` ${c.bold("systemctl --user stop agenthub")} ${c.dim("# per-user unit")}`
);
} else {
const msg = res.data?.error?.message || `HTTP ${res.status}`;
console.error(c.red(`✖ POST /api/settings/quit → ${msg}`));
}
process.exit(1);
}
const { killed } = res;
const deadline = Date.now() + 15_000;
while ((await serverIsUp()) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
}
if (await serverIsUp()) {
console.error(c.red(`✖ The dashboard at ${baseUrl()} is still answering after 15 s`));
process.exit(1);
}
console.log(
`${c.green("✔")} AgentHub stopped${killed ? ` — ${killed} terminal session(s) closed` : ""}. Start it again with: ${c.bold("agenthub start")}`
);
}
/**
* Uninstall AgentHub (scripts/uninstall.js): without `--yes` it only lists what
* would go. Needs no server — it is the thing being removed.
*/
function cmdUninstall(flags) {
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
const uninstall = require(path.join(REPO_ROOT, "scripts", "uninstall.js"));
if (flags.yes !== true) {
const steps = uninstall.plan();
if (!steps.length) return console.log(`${c.dim("○")} Nothing of AgentHub's is left here`);
console.log(c.bold("agenthub uninstall --yes would:"));
for (const s of steps) {
console.log(` ${c.red("✖")} ${s.label}`);
for (const i of s.items) console.log(c.dim(` ${i}`));
}
console.log(
c.dim(
" Kept: the AI tools' own conversations, workspace worktrees and branches, your repos."
)
);
console.error(c.red("Nothing was removed. Re-run with --yes: agenthub uninstall --yes"));
process.exitCode = 1;
return;
}
const r = uninstall.run({ onLine: (l) => console.log(l) });
console.log(c.dim(`Log: ${r.log}`));
if (!r.ok) process.exitCode = 1;
}
/**
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
* (macOS) so the dashboard survives reboots without anything (Claude Code
@@ -2303,6 +2406,17 @@ async function cmdLockAcquire(args) {
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
return;
}
// Only 409 means "held by someone else"; any other answer (a refused
// name, a server error) will not change by waiting.
if (result.status !== 409) {
console.error(
c.red(
`✖ ${result.data?.error?.message || `could not acquire lock (HTTP ${result.status})`}`
)
);
process.exitCode = 1;
return;
}
if (Date.now() - lastPrinted >= 60_000) {
const waited = Math.floor((Date.now() - startedAt) / 1000);
console.log(
@@ -2714,11 +2828,36 @@ const COMMAND_GROUPS = [
[
["status", "", "Up/down indicator for the dashboard server"],
["start", "[--port N]", "Start the server in the background and wait for healthy"],
[
"stop",
"",
"Quit: close every workspace terminal and stop the server until `agenthub start`",
],
[
"uninstall",
"[--yes]",
"Remove AgentHub from this machine, its database included (lists first without --yes)",
],
[
"service",
"install|uninstall|status [--port N]",
"Auto-start on boot via systemd --user (Linux)",
],
[
"server",
"setup|off|status [--hostname name] [--admin name]",
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
],
[
"users",
"list|add|passwd|role|disable|enable|assign|audit [name]",
"Server mode's accounts (add: --role admin|analyst|developer)",
],
[
"accounts",
"list|add|remove [name] [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
],
@@ -2916,6 +3055,11 @@ const COMMAND_GROUPS = [
"",
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
],
[
"tray",
"",
"Show AgentHub's icon in the OS status area now (the server keeps it up; Settings → System hides it)",
],
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
[
"statusline install|uninstall",
@@ -3668,6 +3812,9 @@ const COMMANDS = (() => {
const SUBCOMMANDS = {
alerts: ["ack", "ack-all"],
service: ["install", "uninstall", "status"],
server: ["setup", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
accounts: ["list", "add", "remove"],
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
@@ -3708,6 +3855,519 @@ function cmdStatusline(sub, flags) {
}
}
// ── Server mode (docs/SERVER-MODE.md) ──────────────────────────────────────
/** The auth module over the SAME database and settings the server reads: the
* repo `.env`, then AgentHub's settings file (a pinned DASHBOARD_DATA_DIR). */
function serverAuth() {
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
return { settingsEnv, auth: require(path.join(REPO_ROOT, "server", "lib", "auth.js")) };
}
/** One password: typed hidden on a terminal, else the first line of stdin. */
async function askPassword(prompt) {
const stdin = process.stdin;
if (!stdin.isTTY) {
const chunks = [];
for await (const chunk of stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8").split(/\r?\n/)[0];
}
process.stdout.write(prompt);
return new Promise((resolve, reject) => {
let typed = "";
const finish = () => {
stdin.setRawMode(false);
stdin.pause();
stdin.off("data", onData);
process.stdout.write("\n");
};
const onData = (text) => {
for (const ch of text) {
if (ch === "\r" || ch === "\n") return (finish(), resolve(typed));
if (ch === "\u0003") return (finish(), reject(new Error("cancelled")));
typed = ch === "\u007f" || ch === "\b" ? typed.slice(0, -1) : typed + ch;
}
};
stdin.setRawMode(true);
stdin.setEncoding("utf8");
stdin.on("data", onData);
stdin.resume();
});
}
/** A new password, typed twice on a terminal. */
async function newPassword(label) {
const first = await askPassword(`${label}: `);
if (process.stdin.isTTY && (await askPassword("Again: ")) !== first) {
throw new Error("the two passwords differ");
}
return first;
}
/** `agenthub server setup|off|status` — turn server mode on or off. */
async function cmdServer(sub, flags) {
const { settingsEnv, auth } = serverAuth();
const file = settingsEnv.settingsFilePath();
const envNote = (key) => {
if (settingsEnv.envOwned.has(key)) {
console.log(
c.yellow(`! ${key} is also set in the environment or .env, which wins over ${file}`)
);
}
};
if (sub === "setup") {
if (flags.hostname) {
const hosts = String(flags.hostname);
if (!/^[a-z0-9.\-:[\],]+$/i.test(hosts)) throw new Error(`bad --hostname: ${hosts}`);
settingsEnv.writeSetting("DASHBOARD_ALLOWED_HOSTS", hosts);
envNote("DASHBOARD_ALLOWED_HOSTS");
}
if (!auth.hasAdmin()) {
const name = typeof flags.admin === "string" ? flags.admin : "admin";
const user = await auth.createUser(
name,
await newPassword(`Password for admin ${name}`),
"admin"
);
console.log(`${c.green("✔")} admin ${user.username} created`);
} else {
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
}
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(
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."
);
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>")
);
return;
}
if (sub === "install-system") return installSystem(flags, settingsEnv);
if (sub === "tools") {
// S3/S4: copy this install's scripts and every installed AI tool under
// /opt/agenthub, where every account's pane runs them (the server also
// does this at start and after each install/update).
const shared = require(path.join(REPO_ROOT, "server", "lib", "shared-tools.js"));
await shared.publishApp(REPO_ROOT);
const rows = await shared.publishAll();
table(
["tool", "shared copy"],
rows.map((r) => [
r.id,
r.error ? c.red(r.error) : `${r.path}${r.copied ? "" : c.dim(" (unchanged)")}`,
])
);
return;
}
if (sub === "off") {
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
envNote("AGENTHUB_MODE");
console.log(`${c.green("✔")} server mode off — restart the dashboard. Users are kept.`);
return;
}
if (sub === "status" || sub === undefined) {
const users = auth.listUsers();
console.log(`mode: ${auth.isServerMode() ? "server" : "local"}`);
console.log(`settings: ${file}`);
console.log(
`users: ${users.length} (${users.filter((u) => u.role === "admin").length} admin)`
);
console.log(c.dim(" a change takes effect when the dashboard restarts"));
return;
}
console.error(
c.red(
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
)
);
process.exitCode = 1;
}
/**
* `agenthub server install-system` — one Linux user per account (S3,
* docs/SERVER-MODE.md). Run as the HOST account, never as root: it resolves
* this install's paths as the host, hands them to the root half
* (`scripts/server/install-system.sh`) through ONE `sudo`, points the system
* unit at this install, and moves the dashboard from the `systemd --user`
* unit to the system one (panes survive: KillMode=process). `--uninstall`
* goes back to the user unit.
*/
async function installSystem(flags, settingsEnv) {
const os = require("node:os");
if (process.getuid && process.getuid() === 0) {
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
}
const which = (bin) =>
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
const host = os.userInfo().username;
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
const data = getDataDir();
const port = String(process.env.DASHBOARD_PORT || "4820");
const script = path.join(REPO_ROOT, "scripts", "server", "install-system.sh");
const args = flags.uninstall
? [script, "--uninstall", "--host", host]
: [
script,
...["--host", host, "--node", process.execPath, "--root", REPO_ROOT, "--data", data],
...["--port", port, "--tmux", which("tmux"), "--git", which("git")],
...(which("gh") ? ["--gh", which("gh")] : []),
];
if (flags["dry-run"]) {
console.log(["sudo", "bash", ...args].join(" "));
return;
}
const userUnit = (...a) => spawnSync("systemctl", ["--user", ...a, "agenthub.service"]);
// What either unit runs: this install's node and server, with its env.
const runtime = path.join(data, "runtime");
const clientDist = fs.existsSync(path.join(REPO_ROOT, "client", "dist", "index.html"))
? path.join(REPO_ROOT, "client", "dist")
: path.join(runtime, "client-dist");
const env = { NODE_ENV: "production", PATH: process.env.PATH || "", DASHBOARD_PORT: port };
env.DASHBOARD_CLIENT_DIST = clientDist;
if (!fs.existsSync(path.join(REPO_ROOT, "node_modules"))) {
env.NODE_PATH = path.join(runtime, "node_modules");
}
const serverArgv = [process.execPath, path.join(REPO_ROOT, "server", "index.js")];
if (flags.uninstall) {
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
throw new Error("install-system --uninstall failed");
}
// Repoint the user unit first: the build it named may have been pruned
// while the system unit was the one protected.
require(path.join(REPO_ROOT, "scripts", "systemd-service.js")).writeUserService({
argv: serverArgv,
env,
});
userUnit("enable", "--now");
console.log(`${c.green("✔")} back on the systemd --user unit`);
return;
}
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
throw new Error("install-system failed — nothing was started");
}
// The unit reads node, the plugin root and the env from a file this account
// owns (the bootstrap repoints it after an update without root).
const sys = require(path.join(REPO_ROOT, "scripts", "systemd-system-service.js"));
const wrote = sys.writeUserService({ argv: serverArgv, env });
if (wrote === "failed" || wrote === "unavailable") {
throw new Error(`could not write ${sys.envPath()} (${wrote})`);
}
settingsEnv.writeSetting(
"AGENTHUB_USERS_ROOT",
require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js")).serverConf().usersRoot
);
// Hand over: the user unit stops (its panes stay), and so does a dashboard
// started without one (detached) — else the system unit finds the port
// taken and the old process, without the S3 groups, keeps serving.
userUnit("disable", "--now");
const boot = require(path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"));
const sysUp = () =>
spawnSync("systemctl", ["is-active", "agenthub.service"], {
encoding: "utf8",
}).stdout.trim() === "active";
// A re-run while the system unit serves: that server is the one to keep.
if (!sysUp()) boot.stopDashboard();
for (let i = 0; i < 50 && !sysUp() && boot.livePids().length; i++) {
spawnSync("sleep", ["0.2"]);
}
if (!sysUp() && boot.livePids().length) {
throw new Error("a dashboard is still running — stop it first");
}
if (spawnSync("sudo", ["-n", "systemctl", "enable", "agenthub.service"]).status !== 0) {
console.log(c.yellow("! could not enable agenthub.service for boot"));
}
if (spawnSync("sudo", ["-n", "systemctl", "start", "--no-block", "agenthub.service"]).status) {
throw new Error("could not start agenthub.service — `sudo systemctl status agenthub`");
}
spawnSync("sleep", ["3"]);
if (!sysUp()) {
throw new Error("agenthub.service did not stay up — `sudo journalctl -u agenthub -n 50`");
}
console.log(`${c.green("✔")} the dashboard now runs as the system unit agenthub.service`);
console.log(" Give the existing users their Linux accounts: agenthub users provision --all");
console.log(
c.dim(" Log out and back in once: your own session joins groups agenthub/agenthub-proc.")
);
}
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
async function cmdUsers(sub, positional, flags) {
const { auth } = serverAuth();
const name = positional[0];
const need = () => {
if (!name) throw new Error(`usage: agenthub users ${sub} <username>`);
};
switch (sub) {
case undefined:
case "list":
table(
["id", "username", "role", "state"],
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
);
return;
case "add": {
need();
const role = typeof flags.role === "string" ? flags.role : "developer";
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
return;
}
case "passwd":
need();
await auth.setPassword(name, await newPassword(`New password for ${name}`));
console.log(`${c.green("✔")} password changed — ${name}'s logins ended`);
return;
case "role":
need();
auth.setRole(name, positional[1]);
console.log(`${c.green("✔")} ${name} is now ${positional[1]}`);
return;
case "assign":
if (!name || !positional[1]) {
throw new Error("usage: agenthub users assign <workspace id> <username>");
}
auth.assignWorkspace(name, positional[1]);
console.log(`${c.green("✔")} workspace ${name} now belongs to ${positional[1]}`);
return;
case "audit": {
const { listAudit } = require(path.join(REPO_ROOT, "server", "lib", "audit.js"));
table(
["when", "reader", "owner", "path"],
listAudit(typeof flags.limit === "string" ? flags.limit : 50).map((a) => [
a.at.replace("T", " ").slice(0, 19),
a.reader,
a.owner,
a.path,
])
);
return;
}
case "provision": {
// S3: the Linux account of a user made before install-system (or after a
// failed create). Idempotent: an existing member account is kept.
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
if (!linux.s3Enabled()) {
throw new Error(
"one Linux user per account is not installed (agenthub server install-system)"
);
}
const names = flags.all
? auth
.listUsers()
.filter((u) => !u.disabled)
.map((u) => u.username)
: (need(), [name]);
const memberClaude = require(path.join(REPO_ROOT, "server", "lib", "member-claude.js"));
for (const n of names) {
// One user that cannot be provisioned (a name Linux refuses, a folder
// already there) is reported and skipped — never the end of the run.
try {
console.log(`${c.green("✔")} ${linux.provision("create", n)}`);
} catch (e) {
console.error(c.red(`✖ ${n}: ${e.message}`));
process.exitCode = 1;
continue;
}
// Opt-in plugin baseline (/etc/agenthub/plugin-baseline.json); prints per entry.
await memberClaude.applyPluginBaseline(n);
}
return;
}
case "disable":
case "enable":
need();
auth.setDisabled(name, sub === "disable");
console.log(`${c.green("✔")} ${name} ${sub}d`);
return;
default:
console.error(
c.red(
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | provision <name>|--all | assign <workspace id> <name> | audit [--limit N]"
)
);
process.exitCode = 1;
}
}
/** `agenthub accounts … --tool codex` — the Codex account pool (server/lib/codex-pool.js). */
async function cmdCodexAccounts(sub, name, flags) {
const pool = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
if (sub === "add") {
let added;
if (flags["api-key"]) {
added = pool.addAccount(name, { apiKey: await askPassword(`OpenAI API key for ${name}: `) });
} else if (typeof flags.from === "string") {
// A `codex login` home (or its auth.json) — read here, on the host's shell.
const from = path.resolve(flags.from);
const file = fs.statSync(from).isDirectory() ? path.join(from, "auth.json") : from;
added = pool.addAccount(name, { auth: JSON.parse(fs.readFileSync(file, "utf8")) });
console.log(c.dim(` You can delete ${file} now; the pool keeps its own copy.`));
} else {
throw new Error(
"usage: agenthub accounts add <name> --tool codex --api-key | --from <dir>\n" +
" A ChatGPT account: `CODEX_HOME=$(mktemp -d) codex login` signed in as it, then --from that dir."
);
}
console.log(`${c.green("✔")} ${added.name} (${added.kind}) added`);
console.log(c.dim(" New Codex sessions now spread over the pool's accounts."));
return;
}
if (sub === "remove") {
pool.removeAccount(name);
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
return;
}
if (sub !== undefined && sub !== "list") {
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool codex");
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
pool
.poolStatus()
.map((a) => [
a.name,
a.kind,
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
a.tokens5h,
a.tokens7d,
a.quota
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–",
a.full ? "full" : "ok",
])
);
}
/** `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
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
const name = positional[0];
if (sub === "add" || sub === "remove") {
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
}
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex");
}
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") {
console.log(
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
);
const added = pool.addAccount(name, await askPassword(`Token for ${name}: `));
console.log(
`${c.green("✔")} ${added.name} added — valid until ${added.expiresAt.slice(0, 10)}`
);
console.log(c.dim(" New Claude sessions now spread over the pool's accounts."));
return;
}
if (sub === "remove") {
pool.removeAccount(name);
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
return;
}
if (sub === undefined || sub === "list") {
await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table(
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
pool
.poolStatus()
.map((a) => [
a.name,
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
a.tokens5h,
a.tokens7d,
pct(a.quota?.fiveHour),
pct(a.quota?.sevenDay),
a.expired
? "expired"
: a.full
? "full"
: a.quota?.error
? `no quota (${a.quota.error})`
: "ok",
])
);
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
return;
}
console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
);
process.exitCode = 1;
}
/** Run one parsed command. Returns the handler's promise; may throw
* ServerDownError (for the caller's offline routing) or call process.exit
* for usage/validation failures. `cmd === "repl"` is handled by the entry
@@ -3723,8 +4383,18 @@ async function runCommand(argv) {
return cmdStatus();
case "start":
return cmdStart(flags);
case "stop":
return cmdStop();
case "uninstall":
return cmdUninstall(flags);
case "service":
return cmdService(positional[0], flags);
case "server":
return cmdServer(positional[0], flags);
case "users":
return cmdUsers(positional[0], positional.slice(1), flags);
case "accounts":
return cmdAccounts(positional[0], positional.slice(1), flags);
case "stats":
return cmdStats();
case "kanban":
@@ -3852,6 +4522,8 @@ async function runCommand(argv) {
return;
}
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
case "tray":
return require(path.join(REPO_ROOT, "scripts", "tray.js")).main();
case "open":
return cmdOpen();
case "version":
+13 -7
View File
@@ -52,13 +52,13 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
@@ -199,7 +199,7 @@ client/
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
│ │ │ ├── ReportSettings.tsx # Settings → Snapshots & reports: scheduled report definitions, Run now, run history + downloads
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │
@@ -259,7 +259,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
Settings[SettingsHub /settings/:section<br/>General: Appearance · Notifications · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · Git (credentials, worktree suggestions)]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings
@@ -525,7 +525,7 @@ function App() {
}
```
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
Every page — `Dashboard`, `History`, `Workspace` (the diff viewer), `SettingsHub`, `SessionDetail`, `SnapshotView` — is a `React.lazy` import in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so it loads on first visit instead of in the initial bundle; `main.tsx` starts the Workspace chunk early on a `/workspace` deep link. xterm and its addons load with the first terminal (`loadXterm` in `TerminalView.tsx`), the sidebar's add/destructive workspace modals on first open, and Sagi only while it is enabled (`SagiMount`). Only the active UI language is fetched (`i18n/index.ts`, one lazy chunk per language; `main.tsx` renders once it is in). `vite build` writes hidden source maps and a `.br`/`.gz` sibling of every text asset ≥ 1 KB. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
### Navigation Flow
@@ -793,6 +793,12 @@ in the file is shown as text, never run (no `rehype-raw`), links open in a new
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
the `.md-preview` rules in `index.css`, built from theme tokens.
#### Metric tooltips (`common:insights.help.*`)
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
`vi`.
---
## Utilities
+14 -10
View File
@@ -2,7 +2,8 @@
<html lang="en" class="dark">
<head>
<!-- Apply the saved appearance before first paint (same keys as
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
src/hooks/useTheme.ts, which re-applies and validates on mount, and
src/lib/fontSize.ts). -->
<script>
try {
var m = localStorage.getItem("theme");
@@ -17,6 +18,9 @@
r.classList.toggle("dark", d);
r.setAttribute("data-mode", d ? "dark" : "light");
if (t && t !== "default") r.setAttribute("data-theme", t);
// Text size (src/lib/fontSize.ts, UI_FONT_SIZES): the root font size.
var f = Number(localStorage.getItem("agenthub.ui.fontSize"));
if (f === 14 || f === 17 || f === 18) r.style.fontSize = f + "px";
} catch (e) {}
</script>
<meta charset="UTF-8" />
@@ -30,7 +34,7 @@
<title>AgentHub - SmartGift Solution</title>
<meta
name="description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta
name="keywords"
@@ -45,23 +49,23 @@
<meta property="og:title" content="AgentHub - SmartGift Solution" />
<meta
property="og:description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta property="og:image" content="/og-image.svg" />
<meta property="og:image" content="/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
<meta
name="twitter:description"
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta name="twitter:image" content="/og-image.svg" />
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta name="twitter:image" content="/og-image.png" />
<meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<meta name="twitter:creator" content="@smartgift" />
<!-- JSON-LD structured data -->
@@ -70,7 +74,7 @@
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "AgentHub",
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
"description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
"softwareVersion": "1.0.0",
+587 -548
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+9 -21
View File
@@ -1,12 +1,15 @@
/**
* @description Service Worker for caching static assets and handling push notifications.
* @description Service Worker: network-first navigations with an offline fallback, and push
* notifications. Hashed `/assets/` bundles are left to the HTTP cache (immutable).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// Bump this any time the SW logic changes - old clients will install the new
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
// freshly-built bundle starts being served on the very next request.
const CACHE_NAME = "dashboard-v2";
// v3: drops v2's cache-first `/assets/` entries, which grew with every build
// and were never pruned.
const CACHE_NAME = "dashboard-v3";
self.addEventListener("install", () => {
// No pre-cache: network-first below means the cache fills lazily, and
@@ -39,25 +42,10 @@ self.addEventListener("fetch", (event) => {
)
return;
// Hashed bundles under /assets/ are immutable for a given URL - cache-first
// is safe and fast. A new build emits new filenames, so stale entries simply
// don't get re-requested.
if (url.pathname.startsWith("/assets/")) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ||
fetch(request).then((response) => {
if (response.ok && response.type === "basic") {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
)
);
return;
}
// Hashed bundles under /assets/ are immutable and the server marks them so:
// the browser's HTTP cache keeps them. Caching them here too only grew a
// second copy of every build, forever.
if (url.pathname.startsWith("/assets/")) return;
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
// with cache fallback. The user always gets the freshest UI while online and
+61 -7
View File
@@ -38,8 +38,8 @@
* ## Internal dependencies
* - `./components/Layout`
* - `./components/SplashScreen`
* - `./pages/Dashboard`
* - `./pages/History`
* - `./pages/Dashboard` (lazy)
* - `./pages/History` (lazy)
* - `./pages/SessionDetail` (lazy)
* - `./pages/SettingsHub` (lazy)
* - `./pages/Workspace` (lazy)
@@ -64,8 +64,7 @@ import { GlobalTooltip } from "./components/Tooltip";
import { Toaster } from "./components/Toaster";
import { ConfirmHost } from "./components/ConfirmHost";
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
import { Dashboard } from "./pages/Dashboard";
import { History } from "./pages/History";
import { usePermissions } from "./components/AuthGate";
import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
@@ -75,7 +74,10 @@ import { reloadOnNewBuild } from "./lib/newBuildReload";
import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
// visit instead of in the initial bundle.
// visit instead of in the initial bundle — the Dashboard and History too, so the
// entry chunk holds only the shell.
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History })));
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
const SettingsHub = lazy(() =>
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
@@ -86,6 +88,11 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
const Administration = lazy(() =>
import("./pages/Administration").then((m) => ({ default: m.Administration }))
);
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
function PageFallback() {
const { t } = useTranslation();
@@ -105,6 +112,11 @@ function Page({ children }: { children: ReactNode }) {
);
}
/** A page only a shared server needs (the File Explorer): local mode goes home. */
function ServerOnly({ children }: { children: ReactNode }) {
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
}
/** Keyed by the id so moving to another session starts from fresh state. */
function SessionDetailRoute() {
const { id } = useParams();
@@ -156,8 +168,22 @@ export default function App() {
<DocumentTitle />
<Routes>
<Route element={<Layout wsConnected={connected} />}>
<Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} />
<Route
index
element={
<Page>
<Dashboard />
</Page>
}
/>
<Route
path="sessions"
element={
<Page>
<History />
</Page>
}
/>
<Route
path="sessions/:id"
element={
@@ -194,6 +220,34 @@ export default function App() {
</Page>
}
/>
<Route
path="artifacts"
element={
<Page>
<Artifacts />
</Page>
}
/>
<Route
path="files"
element={
<ServerOnly>
<Page>
<FilesPage />
</Page>
</ServerOnly>
}
/>
<Route
path="admin"
element={
<Page>
<Administration />
</Page>
}
/>
{/* The Users page became Administration's first tab. */}
<Route path="users" element={<Navigate to="/admin" replace />} />
<Route
path="settings/*"
element={
+15 -7
View File
@@ -92,6 +92,8 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import { useAiTools } from "../hooks/useAiTools";
import { useSpin } from "../hooks/useSpin";
import { usePermissions } from "./AuthGate";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -283,7 +285,9 @@ export function AlertsNotifications() {
const { t } = useTranslation("alerts");
const { t: ts } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("rules");
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
const { isAdmin } = usePermissions();
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
// Rules
const [rules, setRules] = useState<AlertRule[]>([]);
@@ -342,6 +346,7 @@ export function AlertsNotifications() {
setLoadingAlerts(false);
}
}, [unackedOnly, t]);
const [spinning, refreshAlerts] = useSpin(loadAlerts);
const loadMore = useCallback(async () => {
try {
@@ -359,8 +364,8 @@ export function AlertsNotifications() {
}, [unackedOnly, alerts.length, t]);
useEffect(() => {
loadRules();
}, [loadRules]);
if (isAdmin) loadRules();
}, [loadRules, isAdmin]);
useEffect(() => {
loadAlerts();
@@ -502,7 +507,7 @@ export function AlertsNotifications() {
(form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{
key: "rules",
label: ts("alertsHub.tabRules"),
@@ -517,6 +522,7 @@ export function AlertsNotifications() {
badge: unacked || undefined,
},
];
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
return (
<div className="space-y-4">
@@ -1017,11 +1023,13 @@ export function AlertsNotifications() {
label={t("feed.unackedOnly")}
/>
<button
onClick={() => loadAlerts()}
onClick={refreshAlerts}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
title={t("refresh")}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
</button>
{unacked > 0 && (
<button
@@ -1082,7 +1090,7 @@ export function AlertsNotifications() {
)}
</p>
</div>
{!alert.acknowledged_at && (
{!alert.acknowledged_at && alert.can_ack !== false && (
<button
onClick={() => onAck(alert.id)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
+286
View File
@@ -0,0 +1,286 @@
/**
* @file AuthGate.tsx
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
* mode — the default — it renders the app and nothing else changes, at once
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
* someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
* previous user's data. `useAuth()` gives the sidebar the user, `logout`, and
* `updateUser` (Settings → Account's saved display name shows at once);
* `usePermissions()` says what that user's role may do, so pages hide the rest.
* Once an account is known (local mode is one), its own UI preferences are
* loaded into this browser (`lib/userPrefs.ts`).
* The login page follows the OS's light/dark (`applySystemTheme`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { BRAND_LOGO } from "../lib/brand";
import { applySystemTheme } from "../hooks/useTheme";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import { hydrate } from "../lib/userPrefs";
type AuthValue = {
state: AuthState;
logout: () => Promise<void>;
updateUser: (patch: Partial<AuthUser>) => void;
};
const AuthContext = createContext<AuthValue>({
state: { mode: "local" },
logout: async () => {},
updateUser: () => {},
});
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
export const useAuth = () => useContext(AuthContext);
/**
* What the logged-in user may do — the server's rules (server/lib/auth.js,
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
* only their own — the one that matters is an analyst looking at another
* user's.
*/
export function usePermissions() {
const { state } = useAuth();
const user = state.mode === "server" ? state.user : null;
const local = state.mode !== "server";
const isAdmin = local || user?.role === "admin";
return {
isAdmin,
// Local mode: one person on their own machine — what only serves a
// shared server (File Explorer, uploads, AgentHub's own git logins) is
// hidden; their desktop and their own git config already do it.
local,
seesAll: local || (!!user && user.role !== "developer"),
// A developer only ever sees their own rows, so everything shown is theirs.
canEditLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
};
}
/** The mode this browser last saw, so a local dashboard paints at once. */
const MODE_KEY = "agenthub.authMode";
const RETRY_MS = 2000;
function cachedState(): AuthState | null {
try {
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
} catch {
return null;
}
}
export function AuthGate({ children }: { children: ReactNode }) {
// null = still asking the server. A browser that last saw local mode starts
// there and is corrected by the answer (or by a 401) if that changed.
const [state, setState] = useState<AuthState | null>(cachedState);
useEffect(() => {
let timer: number | undefined;
let alive = true;
const ask = () => {
api.auth
.me()
.then((next) => {
if (!alive) return;
try {
localStorage.setItem(MODE_KEY, next.mode);
} catch {
/* storage blocked */
}
setState(next);
})
.catch((err: { status?: number; code?: string }) => {
if (!alive) return;
// An answer that is not this build's: an older server still running
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
// app as before and let it show its own state.
if (
err?.status === 404 ||
err?.code === "EBADRESPONSE" ||
(err?.status === 401 && err?.code !== "EAUTH")
) {
setState({ mode: "local" });
return;
}
// No answer at all (a restart, a proxy's 502): ask again rather than
// guess — "local" would hide a server-mode dashboard's login page.
timer = window.setTimeout(ask, RETRY_MS);
});
};
ask();
// Any request answering 401 EAUTH means server mode with no valid login.
const onRequired = () => setState({ mode: "server", user: null });
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
return () => {
alive = false;
window.clearTimeout(timer);
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
};
}, []);
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
// per account, so a second login in this browser brings that account's.
const account =
state?.mode === "local"
? "local"
: state?.mode === "server" && state.user
? `u${state.user.id}`
: null;
useEffect(() => {
if (account) void hydrate();
}, [account]);
// Stable, so a page may list it as an effect dependency.
const updateUser = useCallback(
(patch: Partial<AuthUser>) =>
setState((s) =>
s && s.mode === "server" && s.user ? { ...s, user: { ...s.user, ...patch } } : s
),
[]
);
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
}
const logout = async () => {
await api.auth.logout().catch(() => {});
setState({ mode: "server", user: null });
};
return (
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
);
}
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Nobody is signed in, so no account's theme applies: follow the OS, live.
useLayoutEffect(() => {
applySystemTheme();
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
mq?.addEventListener?.("change", applySystemTheme);
return () => mq?.removeEventListener?.("change", applySystemTheme);
}, []);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
onLoggedIn((await api.auth.login(username, password)).user);
} catch (err) {
const code = (err as { code?: string }).code;
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
setPassword("");
} finally {
setBusy(false);
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form
onSubmit={submit}
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
>
{/* Accent glow and a grid fading out from the top, drawn from the
theme's own accent so it holds in every theme. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-56"
style={{
backgroundImage:
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
backgroundPosition: "center top",
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
}}
/>
<div className="relative space-y-5">
<div className="text-center">
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
{t("auth.title")}
</h1>
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
</div>
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
<span>{t("auth.username")}</span>
<input
className="input h-11 w-full px-4"
autoComplete="username"
autoFocus
required
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
<label htmlFor="login-password">{t("auth.password")}</label>
<div className="relative">
<input
id="login-password"
className="input h-11 w-full pl-4 pr-11"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{/* Inverted fill (text colour behind the surface colour): black in a
light theme, light in a dark one, like the reference design. */}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={busy}
>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
</div>
</form>
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* @file ImageLightbox.tsx
* @description One image shown full size over the whole page; a click or
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
* layer or a header drawn over a pane can cover it — opened from inside a
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
* layer, under anything stacked above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { createPortal } from "react-dom";
export function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal(
<div
role="dialog"
aria-label={alt}
data-testid="image-lightbox"
onClick={onClose}
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={src}
alt={alt}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>,
document.body
);
}
+22 -4
View File
@@ -51,7 +51,8 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
* - `./Sagi/Sagi`
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
* - `./Sagi/SagiMount`
*
* ## Public surface
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
@@ -75,14 +76,23 @@
*
* ----------------------------------------------------------------------------- */
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";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
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. */
@@ -132,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);
@@ -171,7 +182,14 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
</a>
<UpdateNotifier />
<Sagi />
<InterruptedSessionsPrompt />
<SagiMount />
{onboarding.loaded && !onboarding.onboardedAt && (
<Suspense fallback={null}>
<OnboardingWizard />
</Suspense>
)}
<OnboardingTour />
{drawer && drawerOpen && (
<div
aria-hidden
+8 -1
View File
@@ -14,6 +14,7 @@ import { BellRing } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { timeAgo } from "../lib/format";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { workspaceHref } from "./SessionRowMenu";
import type { Session } from "../lib/types";
@@ -32,6 +33,9 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
setTotal(r.total);
})
.catch(() => undefined); // the strip is a shortcut; the pages show errors
}, []);
// The live panes (for the links) move only when a lane's run columns do.
const loadRuns = useCallback(() => {
api.run
.list()
.then((r) => setRuns(r.items))
@@ -40,11 +44,14 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
useEffect(() => {
load();
}, [load]);
loadRuns();
}, [load, loadRuns]);
useLiveRefresh(load, {
intervalMs: 15000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
useLiveRefresh(loadRuns, { intervalMs: 15000, match: runChanged });
if (sessions.length === 0) return null;
+8 -16
View File
@@ -6,9 +6,9 @@
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* with no desktop or no zenity/kdialog) it falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure) — for a file field
* too, which then lists files and picks one on click.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,12 +16,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
@@ -44,15 +40,10 @@ export function PathPickButton({
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} catch {
// The in-app browser is the fallback whatever went wrong with the
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
setFallback(true);
} finally {
setBusy(false);
}
@@ -76,6 +67,7 @@ export function PathPickButton({
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
kind={kind}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
+591
View File
@@ -0,0 +1,591 @@
/**
* @file PetChat.tsx
* @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
* chat bubbles read from the session's transcript. Follow-ups are typed into
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
* 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, 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, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
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";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
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";
import { MarkdownContent } from "../conversation/MarkdownContent";
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
const CHAT_KEY = "agenthub.pet.chat";
const POLL_MS = 1500;
const MENU_POLL_MS = 700;
// A follow-up waits this long for the off-screen pane to attach.
const SEND_TRIES = 20;
const SEND_RETRY_MS = 250;
/** The session the chat is talking to. */
export interface PetChatRef {
laneId: number;
runId: string;
tool: string;
}
export function readPetChat(): PetChatRef | null {
try {
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
if (
v &&
typeof v.laneId === "number" &&
typeof v.runId === "string" &&
typeof v.tool === "string"
)
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
} catch {
/* unreadable: no chat */
}
return null;
}
function writePetChat(ref: PetChatRef | null) {
try {
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
else localStorage.removeItem(CHAT_KEY);
} catch {
/* best-effort */
}
}
/** The tool a new chat starts: the user's default, when the chat can show it. */
export function petChatTool(): string | null {
const tool = readDefaultTool() ?? "claude";
return PET_CHAT_TOOLS.has(tool) ? tool : null;
}
type Line =
| { key: string; role: "user" | "assistant"; text: string }
| { key: string; role: "tools"; names: string[] };
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
* `<system-reminder>`…) are not something the user said. */
const humanText = (m: TranscriptMessage) =>
m.content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!.trim())
.filter((t) => t && !t.startsWith("<"))
.join("\n\n");
/** The transcript as chat lines: the user's words, the assistant's prose, and
* each run of tool calls between them folded into one chip. */
export function chatLines(messages: TranscriptMessage[]): Line[] {
const out: Line[] = [];
messages.forEach((m, i) => {
const sender = m.sender ?? m.type;
if (sender === "user") {
const text = humanText(m);
if (text) out.push({ key: `u${i}`, role: "user", text });
return;
}
if (m.type !== "assistant") return;
m.content.forEach((c, j) => {
if (c.type === "text" && c.text?.trim()) {
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
} else if (c.type === "tool_use" && c.name) {
const last = out[out.length - 1];
if (last?.role === "tools") last.names.push(c.name);
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
}
});
});
return out;
}
/** Still working: the last line is the user's, or an assistant turn not ended. */
function isBusy(messages: TranscriptMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
const sender = m.sender ?? m.type;
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
if (sender === "user" && humanText(m)) return true;
}
return false;
}
/** "Read ×2 · Bash" */
function toolSummary(names: string[]): string {
const counts = new Map<string, number>();
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
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;
/** 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;
/** The thinking mood while a reply is being written. */
onBusy?: (busy: boolean) => void;
}
export function PetChat({
name,
title,
headerActions,
info,
onLocal,
onClose,
onOpenWorkspace,
onBusy,
}: PetChatProps) {
const { t } = useTranslation("settings");
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// Sent but not in the transcript yet.
const [pending, setPending] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
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) => {
writePetChat(ref);
setChat(ref);
setSessionId(null);
setMessages([]);
setMenu(null);
};
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
useEffect(() => {
if (!chat) return;
let live = true;
const check = () =>
api.run
.get(chat.runId)
.then((h) => {
if (!live) return;
if (h.status !== "running") remember(null);
else if (h.sessionId) setSessionId(h.sessionId);
})
.catch(() => live && remember(null));
void check();
// Codex learns its session only after a while; Claude's is known at start.
const timer = window.setInterval(() => {
if (!sessionId) void check();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat?.runId, sessionId]);
// The conversation, polled while the window is open.
useEffect(() => {
if (!sessionId) return;
let live = true;
const load = () =>
api.sessions
.transcript(sessionId, { limit: 60 })
.then((r) => {
if (!live) return;
setMessages((prev) =>
prev.length === r.messages.length &&
JSON.stringify(prev[prev.length - 1]) ===
JSON.stringify(r.messages[r.messages.length - 1])
? prev
: r.messages
);
})
.catch(() => undefined); // not started yet: the next poll asks again
void load();
const timer = window.setInterval(load, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
// A menu the CLI shows in the pane (permission, trust, a question).
useEffect(() => {
if (!chat) return;
const read = () =>
setMenu((prev) => {
const next = readRunMenu(chat.runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
const timer = window.setInterval(read, MENU_POLL_MS);
return () => window.clearInterval(timer);
}, [chat]);
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()))
setPending(null);
}, [lines, pending]);
const busy = starting || !!pending || isBusy(messages);
useEffect(() => onBusy?.(busy), [busy, onBusy]);
useEffect(() => () => onBusy?.(false), [onBusy]);
// Keep the newest in sight.
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length, pending, busy, menu]);
const send = useCallback(
async (text: string) => {
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.
for (let i = 0; i < SEND_TRIES; i++) {
if (submitToRun(chat.runId, prompt)) return;
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
}
setPending(null);
setDraft(prompt);
setError(t("sagi.chat.notSent"));
return;
}
const tool = petChatTool() ?? "claude";
setStarting(true);
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
setPending(null);
setDraft(prompt);
setError(
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setStarting(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t, onLocal]
);
// 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();
if (!draft.trim()) return;
const text = draft;
setDraft("");
void send(text);
};
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
const empty = lines.length === 0 && !pending;
return (
<div
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={t("sagi.chat.label", { name })}
>
{/* header */}
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
{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 ${
busy
? "animate-pulse bg-status-warning"
: chat
? "bg-status-success"
: "bg-fg-muted"
}`}
aria-hidden
/>
{busy ? t("sagi.chat.typing") : toolLabel}
</div>
</div>
{headerActions}
{chat && (
<IconButton
label={t("sagi.chat.openWorkspace")}
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
>
<ExternalLink size={15} />
</IconButton>
)}
<IconButton
label={t("sagi.chat.new")}
onClick={() => void newChat()}
disabled={(!chat && !local.length) || starting}
>
<SquarePen size={15} />
</IconButton>
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
<X size={16} />
</IconButton>
</div>
{info}
{/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
<span className="text-3xl" aria-hidden>
🐾
</span>
<p className="text-sm font-medium text-fg-primary">
{t("sagi.chat.emptyTitle", { name })}
</p>
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
</div>
)}
{lines.map((l) =>
l.role === "tools" ? (
<div key={l.key} className="flex justify-start">
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
<Wrench size={10} className="shrink-0" aria-hidden />
<span className="truncate">{toolSummary(l.names)}</span>
</span>
</div>
) : l.role === "user" ? (
<div key={l.key} className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
{l.text}
</div>
</div>
) : (
<div key={l.key} className="flex justify-start">
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
<MarkdownContent text={l.text} dense prose />
</div>
</div>
)
)}
{pending && (
<div className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
{pending}
</div>
</div>
)}
{busy && !menu && (
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
</div>
</div>
)}
{starting && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
<Loader2 size={12} className="animate-spin" aria-hidden />
{t("sagi.chat.starting", { tool: toolLabel })}
</p>
)}
{chat && !sessionId && !starting && (
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
)}
</div>
{error && (
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
{/* prompt, or the question the CLI asks */}
<div className="pet-chat-foot px-2.5 py-2.5">
{menu && chat ? (
<div className="max-h-56 overflow-y-auto">
<PaneMenuCard
menu={menu}
onChoose={(i) => {
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onAnswer={(text) => {
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
/>
</div>
) : (
<form onSubmit={submit} className="flex items-end gap-1.5">
<textarea
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.chat.placeholder", { name })}
aria-label={t("sagi.chat.placeholder", { name })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClose();
}
}}
/>
<button
type="submit"
disabled={!draft.trim() || starting}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
aria-label={t("sagi.panel.send")}
>
<ArrowUp size={16} />
</button>
</form>
)}
</div>
{/* The chat's pane, attached off-screen: the only way a follow-up or a
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
{chat && (
<div className="pet-chat-term" aria-hidden>
<TerminalView
runId={chat.runId}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={false}
// The pane ended: the conversation stays on screen, the next
// message starts a new session.
onExit={() => {
writePetChat(null);
setChat(null);
}}
/>
</div>
)}
</div>
);
}
function IconButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
{children}
</button>
);
}
+49 -39
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { usePermissions } from "../AuthGate";
import { toast } from "../../lib/toast";
const ITEM =
@@ -50,6 +51,8 @@ export function PetMenu({
const bundleInput = useRef<HTMLInputElement>(null);
const [choosing, setChoosing] = useState(false);
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Adding or deleting a pet changes the whole install: an admin's.
const { isAdmin } = usePermissions();
// Closes on a press outside the menu, or Escape.
useEffect(() => {
@@ -133,6 +136,7 @@ export function PetMenu({
</button>
{list?.featured.map((f) => {
const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return (
<button
key={f.id}
@@ -173,48 +177,54 @@ export function PetMenu({
<span className="min-w-0 truncate">{p.displayName}</span>
{mark(p.id)}
</button>
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{isAdmin && (
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
{isAdmin && (
<>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
</>
)}
<a
href="https://codex-pets.net/"
target="_blank"
+85 -42
View File
@@ -1,14 +1,18 @@
/**
* @file PetSprite.tsx
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
* animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review) — played one row at a time by Sagi's mood; an
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
* one per animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
* timings; a moment (waving, jumping, failed, review) plays three times and
* settles into idle, a condition loops. An `action` (dragged left/right,
* hovered) plays the run-left/run-right or waving row over the mood's, like
* Codex's own pet; a `moment` (a new session waves, a finish jumps, an error
* fails, a wait waits, a new subagent reviews — `useSagiBrain`) plays its row
* over the mood's, under the action; a v2 pet at idle holds the gaze pose `gaze` names. An
* uploaded image is shown still and bobs with the same `data-mood` CSS as
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
* or waving row over the mood's, like Codex's own pet. Each row steps at its
* own pace and holds its last frame longer, so a loop breathes instead of
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls
* back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -16,12 +20,32 @@
import { useEffect, useState } from "react";
import { api } from "../../lib/api";
import type { Mood } from "./brain";
import type { Mood, PetMoment } from "./brain";
const CELL = { w: 192, h: 208 };
const SHEET = { cols: 8, rows: 9 };
/** Frames in each row of the Codex atlas. */
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
const COLS = 8;
/** `n` frames of `step` ms, the last one `last` ms. */
const frames = (n: number, step: number, last: number) =>
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
* how a sleeping pet breathes here. */
const DURATIONS = [
[280, 110, 110, 140, 140, 320], // idle
frames(8, 120, 220), // run right
frames(8, 120, 220), // run left
frames(4, 140, 280), // waving
frames(5, 140, 280), // jumping
frames(8, 140, 240), // failed
frames(6, 150, 260), // waiting
frames(6, 120, 220), // running (working)
frames(6, 150, 280), // review
];
const SLEEP_SLOWDOWN = 6;
/** Rows that play three times and settle into idle; the others loop. */
const MOMENTS = new Set([3, 4, 5, 8]);
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
const GAZE_ROW = 9;
/** Which row plays for each mood. */
const ROW: Record<Mood, number> = {
idle: 0,
@@ -36,25 +60,19 @@ const ROW: Record<Mood, number> = {
/** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
const PACE: [number, number][] = [
[220, 1400], // idle: a breath, then a pause
[120, 120], // run right — keeps up with the drag
[120, 120], // run left
[180, 400], // waving
[160, 700], // jumping
[200, 1200], // failed
[220, 900], // waiting
[170, 400], // running (working)
[220, 900], // review
];
const SLEEP_PACE: [number, number] = [450, 2000];
/** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
* and the user's own action outranks it. */
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
/** Loops of a row before the sprite rests on its first frame. */
const SPRITE_LOOPS = 4;
export function PetSprite({
id,
kind,
mood,
action,
moment,
gaze,
size,
reducedMotion,
onBroken,
@@ -63,31 +81,44 @@ export function PetSprite({
kind: "codex" | "image";
mood: Mood;
action?: PetAction | null;
moment?: PetMoment | null;
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
gaze?: number | null;
size: number;
reducedMotion: boolean;
onBroken: () => void;
}) {
const url = api.pets.spriteUrl(id);
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
const sleeping = !action && mood === "sleeping";
const [frame, setFrame] = useState(0);
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
const sleeping = !action && !moment && mood === "sleeping";
// The cell on show, tagged with the row it was played for: a cell left over
// from the previous row (say column 7 of a run) is never drawn on the next.
const [cell, setCell] = useState({ of: row, row, col: 0 });
const [rows, setRows] = useState(9);
useEffect(() => {
setFrame(0);
setCell({ of: row, row, col: 0 });
if (kind !== "codex" || reducedMotion) return;
const count = FRAMES[row] ?? 1;
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
let f = 0;
const play = (r: number, slow = 1) =>
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
let i = 0;
let loops = 0;
let timer = 0;
// `i` always indexes `list`: it wraps at its end. A few loops, then the
// first frame rests — a sprite that never stops re-paints the page several
// times a second for as long as it is open; a new mood or action plays again.
const tick = () => {
timer = window.setTimeout(
() => {
f = (f + 1) % count;
setFrame(f);
tick();
},
f === count - 1 ? hold : step
);
timer = window.setTimeout(() => {
if (++i === list.length) {
i = 0;
if (MOMENTS.has(row)) list = play(0);
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
}
setCell(list[i]!);
tick();
}, list[i]!.ms);
};
tick();
return () => window.clearTimeout(timer);
@@ -96,10 +127,13 @@ export function PetSprite({
// A background image reports no error of its own: probe it once.
useEffect(() => {
if (kind !== "codex") return;
// It also tells a v2 sheet (11 rows) from a v1 (9).
const probe = new Image();
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
probe.onerror = onBroken;
probe.src = url;
return () => {
probe.onload = null;
probe.onerror = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -111,6 +145,7 @@ export function PetSprite({
src={url}
alt=""
data-mood={mood}
data-moment={moment ?? undefined}
className="sagi-pet-image"
width={size}
height={size}
@@ -120,11 +155,19 @@ export function PetSprite({
/>
);
}
const pose =
gaze != null && rows > GAZE_ROW && !action && !moment && mood === "idle" ? gaze : null;
const shown =
pose != null
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
: cell.of === row
? cell
: { row, col: 0 };
const scale = size / CELL.h;
return (
<div
data-testid="pet-sprite"
data-row={row}
data-row={shown.row}
aria-hidden
style={{
width: Math.round(CELL.w * scale),
@@ -132,8 +175,8 @@ export function PetSprite({
margin: "0 auto",
backgroundImage: `url("${url}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
}}
/>
);
+259 -72
View File
@@ -1,17 +1,24 @@
/**
* @file Sagi.tsx
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
* in Layout) so it persists across routes and shares the single WebSocket.
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
* @description Floating mascot companion shell. Mounted once by `SagiMount` (next
* to UpdateNotifier in Layout), which loads this chunk only while the mascot is
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
* tick, WS reducer) runs only while it is on. Persists across routes and
* shares the single WebSocket.
* 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, and the bubble/panel render in a self-clamping flyout so
* 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>
*/
@@ -35,7 +42,7 @@
* ## Internal dependencies
* - `./SagiAvatar`
* - `./SpeechBubble`
* - `./SagiPanel`
* - `./SagiInfo`
* - `./useSagiBrain`
* - `./useSagiPosition`
* - `./intents`
@@ -73,17 +80,32 @@ import {
type ReactNode,
} 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";
import { matchIntent } from "./intents";
import { sagiPrefs } from "./prefs";
import { petName, sagiPrefs } from "./prefs";
import type { SagiSubject } from "./brain";
import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
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.
const STROKE_TURNS = 4;
const STROKE_MS = 1200;
const STROKE_COOLDOWN_MS = 1500;
import "./sagi.css";
const FLYOUT_GAP = 10; // px between avatar and flyout
@@ -109,38 +131,27 @@ 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
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
* into view instead of being cropped.
*/
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
/**
* `front`: above the mascot itself — the pet menu, which may overlap it.
* `beside`: at the mascot's side instead of above it — the chat window, on the
* screen side away from the edge it docks to, its bottom level with the pet's.
*/
function SagiFlyout({
anchor,
children,
front = false,
beside = false,
}: {
anchor: Anchor;
children: ReactNode;
front?: boolean;
beside?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
@@ -153,6 +164,18 @@ function SagiFlyout({
const vw = window.innerWidth;
const vh = window.innerHeight;
if (beside) {
let left =
anchor.side === "left"
? anchor.left + anchor.size + FLYOUT_GAP
: anchor.left - w - FLYOUT_GAP;
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
let top = anchor.top + anchor.size - h;
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
setStyle({ left, top, visibility: "visible" });
return;
}
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
@@ -165,7 +188,7 @@ function SagiFlyout({
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
setStyle({ left, top, visibility: "visible" });
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
useLayoutEffect(() => {
place();
@@ -195,35 +218,63 @@ function SagiFlyout({
);
}
/** Renders nothing — and runs no hook below — while the mascot is disabled. */
export function Sagi() {
return useSagiEnabled() ? <SagiBody /> : null;
}
function SagiBody() {
const { t } = useTranslation("settings");
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [pet, setPet] = useState(() => sagiPrefs.getPet());
// 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 right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const href = useSettingsHref();
const brain = useSagiBrain();
const place = useSagiPosition(size);
// Keep enabled, the pet and its size in sync with Settings / other tabs.
// Keep the pet and its size in sync with Settings / other tabs (enabled is
// the `Sagi` gate's: it unmounts this body when the mascot is turned off).
useEffect(
() =>
sagiPrefs.subscribe(() => {
setEnabled(sagiPrefs.getEnabled());
setPet(sagiPrefs.getPet());
setName(petName(sagiPrefs.getPet()));
setSize(sagiPrefs.getSize());
}),
[]
);
// A pet worn before names rode along with the choice: look its name up once.
const petId = pet.id;
const unnamed = "kind" in pet && !pet.name;
useEffect(() => {
if (!unnamed) return;
let live = true;
api.pets
.list()
.then((list) => {
const found = list.pets.find((p) => p.id === petId);
const cur = sagiPrefs.getPet();
if (live && found && cur.id === petId && "kind" in cur)
sagiPrefs.setPet({ ...cur, name: found.displayName });
})
.catch(() => undefined);
return () => {
live = false;
};
}, [petId, unnamed]);
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
// shown, and never from an editable field, where Ctrl+B belongs to the field.
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
if (isEditable(e.target) || isEditable(document.activeElement)) return;
@@ -235,7 +286,33 @@ export function Sagi() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [enabled]);
}, []);
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
const btn = useRef<HTMLButtonElement>(null);
const [gaze, setGaze] = useState<number | null>(null);
const wearsSheet = "kind" in pet && pet.kind === "codex";
useEffect(() => {
if (!wearsSheet || reducedMotion) return;
let rest = 0;
const onMove = (e: PointerEvent) => {
const r = btn.current?.getBoundingClientRect();
if (!r) return;
const deg =
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
Math.PI;
setGaze(Math.round((deg + 360) / 22.5) % 16);
window.clearTimeout(rest);
rest = window.setTimeout(() => setGaze(null), 2000);
};
window.addEventListener("pointermove", onMove);
return () => {
window.removeEventListener("pointermove", onMove);
window.clearTimeout(rest);
};
}, [wearsSheet, reducedMotion]);
const onNavigate = useCallback(
(route: string) => {
@@ -245,27 +322,48 @@ export function Sagi() {
[navigate]
);
const onAsk = useCallback(
(query: string): string | null => {
const result = matchIntent(query, brain.status);
if (result.kind === "answer") return result.text;
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
// fresh claude session in it carrying the user's prompt as its first
// message, then open that workspace's session view on the new pane.
// Fire-and-forget — the panel already clears its composer on null and
// closes; errors surface as an answer instead of silently swallowing.
// A session a bubble or the panel names: its live pane when a dashboard run
// holds it, else its workspace's replay, else its analysis page. Opening
// never resumes anything.
const openSession = useCallback(
(s: SagiSubject) => {
const go = (runs?: Parameters<typeof workspaceHref>[1]) => {
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
brain.dismissBubble();
setOpen(false);
};
api.run
.list()
.then((r) => go(r.items))
.catch(() => go());
},
[brain, navigate]
);
// 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 { 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;
brain.setThinking(true);
(async () => {
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const run = await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: prompt,
permissionMode: "acceptEdits",
const tool = readDefaultTool();
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool && tool !== lane.tool ? { tool } : {}),
});
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
setOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -274,12 +372,33 @@ export function Sagi() {
brain.setThinking(false);
}
})();
return null;
return { handled: true };
},
[brain, navigate, t]
[brain, navigate, t, name]
);
if (!enabled) return null;
// Stroke the pet (move the pointer back and forth over it) to pat it.
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
const onStroke = (e: React.PointerEvent) => {
if (e.buttons !== 0 || e.pointerType === "touch") return;
const s = stroke.current;
const now = performance.now();
const dx = e.clientX - s.x;
s.x = e.clientX;
if (Math.abs(dx) < 2 || now < s.until) return;
const dir = Math.sign(dx);
if (s.dir && dir !== s.dir) {
s.turns = [...s.turns.filter((t0) => now - t0 < STROKE_MS), now];
if (s.turns.length >= STROKE_TURNS) {
s.turns = [];
s.until = now + STROKE_COOLDOWN_MS;
brain.pat();
}
}
s.dir = dir;
};
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
const anchor: Anchor = {
left: place.left,
@@ -292,18 +411,51 @@ export function Sagi() {
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
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
onAsk={onAsk}
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
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}
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)}`);
setOpen(false);
}}
onBusy={onBusy}
/>
</SagiFlyout>
)}
@@ -316,7 +468,7 @@ export function Sagi() {
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/appearance#pet");
navigate(href("appearance#pet"));
}}
/>
</SagiFlyout>
@@ -324,18 +476,26 @@ export function Sagi() {
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
<SpeechBubble
bubble={brain.bubble}
onDismiss={brain.dismissBubble}
onOpen={openSession}
/>
</SagiFlyout>
)}
<button
ref={btn}
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerMove={(e) => {
place.onPointerMove(e);
onStroke(e);
}}
onPointerUp={place.onPointerUp}
onKeyDown={place.onKeyDown}
onClick={() => {
@@ -350,29 +510,56 @@ export function Sagi() {
setOpen(false);
setMenu(true);
}}
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open}
title={t("sagi.avatarTitle")}
title={t("sagi.avatarTitle", { name })}
>
{pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
<SagiAvatar
mood={brain.mood}
moment={reducedMotion ? null : brain.moment?.kind}
reducedMotion={reducedMotion}
size={size}
/>
) : (
<PetSprite
id={pet.id}
kind={pet.kind}
mood={brain.mood}
// Dragged → runs that way; hovered → waves (Codex's pet does both).
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
// A moment (a pat's hop) outranks the hover wave, or stroking the
// pet would only ever show it waving.
action={place.moving ? `run-${place.moving}` : hover && !brain.moment ? "wave" : null}
moment={brain.moment?.kind}
gaze={gaze}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
/>
)}
{brain.status.errorCount > 0 && (
{brain.reaction && (
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
<span className="sagi-hearts" aria-hidden>
<span>💜</span>
<span>💗</span>
<span>💜</span>
</span>
<span className="sagi-reaction-text" data-side={place.side}>
{brain.reaction.text}
</span>
</span>
)}
{brain.status.errorCount > 0 ? (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
</span>
) : (
brain.status.waitingCount > 0 && (
<span className="sagi-error-dot" data-kind="waiting" aria-hidden>
{brain.status.waitingCount > 9 ? "9+" : brain.status.waitingCount}
</span>
)
)}
</button>
</>
+24 -9
View File
@@ -36,13 +36,15 @@
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
import { useEffect, useRef, useState } from "react";
import type { Mood } from "./brain";
import { memo, useEffect, useRef } from "react";
import type { Mood, PetMoment } from "./brain";
interface SagiAvatarProps {
mood: Mood;
reducedMotion: boolean;
size?: number;
/** A one-shot reaction (hop, wiggle, flinch…) played over the mood. */
moment?: PetMoment | null;
}
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
@@ -76,10 +78,22 @@ if (typeof window !== "undefined") {
});
}
export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps) {
// The pupil offset is written as CSS variables on the root, never as state, so
// following the cursor costs no React render.
function setPupil(el: SVGSVGElement | null, x: number, y: number) {
el?.style.setProperty("--sagi-px", `${x}px`);
el?.style.setProperty("--sagi-py", `${y}px`);
}
// memo: Sagi re-renders on every brain tick; the avatar only on mood/size/motion.
export const SagiAvatar = memo(function SagiAvatar({
mood,
reducedMotion,
size = 68,
moment = null,
}: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>();
const [pupil, setPupil] = useState({ x: 0, y: 0 });
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
@@ -89,7 +103,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
useEffect(() => {
if (!tracking) {
setPupil({ x: 0, y: 0 });
setPupil(rootRef.current, 0, 0);
return;
}
@@ -105,7 +119,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
// Normalize then clamp to the eye socket range.
const nx = (dx / dist) * Math.min(1, dist / 240);
const ny = (dy / dist) * Math.min(1, dist / 240);
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
setPupil(el, nx * MAX_PUPIL_SHIFT, ny * MAX_PUPIL_SHIFT);
};
// Immediately aim at the last-known cursor (next frame, so layout is ready)
@@ -137,6 +151,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
ref={rootRef}
className="sagi-avatar"
data-mood={mood}
data-moment={moment ?? undefined}
data-reduced={reducedMotion ? "1" : "0"}
width={size}
height={size}
@@ -202,7 +217,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
<g className="sagi-eye sagi-eye-left">
<g
className="sagi-eye-track"
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
>
<g className="sagi-eye-blink">
<path
@@ -226,7 +241,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
<g className="sagi-eye sagi-eye-right">
<g
className="sagi-eye-track"
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
>
<g className="sagi-eye-blink">
<path
@@ -792,4 +807,4 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
</g>
</svg>
);
}
});
+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>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @file SagiMount.tsx
* @description Gate for the Sagi mascot: follows the enabled preference
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
* WebSocket reducer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { lazy, Suspense, useEffect, useState } from "react";
import { sagiPrefs } from "./prefs";
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
/** The mascot's enabled preference, followed live (Settings, other tabs). */
export function useSagiEnabled(): boolean {
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
return enabled;
}
export function SagiMount() {
if (!useSagiEnabled()) return null;
return (
<Suspense fallback={null}>
<Sagi />
</Suspense>
);
}
-295
View File
@@ -1,295 +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 {
Play,
Activity,
LayoutList,
Bell,
BellOff,
Trash2,
X,
Send,
AlertTriangle,
Hourglass,
Radio,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SagiStatus } from "./brain";
interface SagiPanelProps {
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({
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
onAsk,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(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")}
>
{/* 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>
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
<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>
<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")}
>
<X size={15} />
</button>
</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>
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton
icon={Play}
label={t("sagi.panel.runClaude")}
onClick={() => onNavigate("/workspace")}
/>
<ActionButton
icon={Activity}
label={t("sagi.panel.activity")}
onClick={() => onNavigate("/activity")}
/>
<ActionButton
icon={LayoutList}
label={t("sagi.panel.sessions")}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={AlertTriangle}
label={t("sagi.panel.errored")}
disabled={status.errorCount === 0}
onClick={() => onNavigate("/sessions")}
/>
<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")}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t("sagi.panel.ask")}
/>
<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 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>
);
}
+31 -9
View File
@@ -1,14 +1,14 @@
/**
* @file SpeechBubble.tsx
* @description Transient speech bubble rendered above the Sagi mascot.
* Pure presentation — visibility timing and quip selection live in
* {@link useSagiBrain}; this component only paints the bubble and handles
* user dismissal.
* Pure presentation — visibility timing, coalescing and quip selection live in
* {@link useSagiBrain}; this component paints the line, the session(s) it is
* about, an Open button when it is about exactly one, and handles dismissal.
*
* ## Accessibility
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
* new quips without interrupting current speech. Click anywhere on the bubble
* to dismiss early.
* new quips without interrupting current speech. Click the bubble to dismiss
* it early; its Open button opens the session instead.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -52,30 +52,52 @@
* ----------------------------------------------------------------------------- */
import { useTranslation } from "react-i18next";
import type { SagiBubble } from "./useSagiBrain";
import type { SagiSubject } from "./brain";
/** Props for {@link SpeechBubble}. */
interface SpeechBubbleProps {
/** Quip text to display inside the bubble. */
text: string;
bubble: SagiBubble;
/** Called when the user clicks to dismiss. */
onDismiss: () => void;
/** Opens the session the bubble is about. */
onOpen?: (subject: SagiSubject) => void;
}
/**
* Animated speech bubble above Sagi.
* @param props See {@link SpeechBubbleProps}.
*/
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
const { t } = useTranslation("settings");
const { text, detail, subject, tone } = bubble;
return (
<div
className="sagi-bubble sagi-bubble-enter"
data-tone={tone}
role="status"
aria-live="polite"
onClick={onDismiss}
title={t("sagi.bubbleDismiss")}
>
{text}
<div>{text}</div>
{(detail || (subject && onOpen)) && (
<div className="sagi-bubble-detail">
{detail && <span className="sagi-bubble-subject">{detail}</span>}
{subject && onOpen && (
<button
type="button"
className="sagi-bubble-open"
onClick={(e) => {
e.stopPropagation();
onOpen(subject);
}}
>
{t("sagi.bubbleOpen")}
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,46 @@
/**
* @file PetChat.test.ts
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { chatLines } from "../PetChat";
import type { TranscriptMessage } from "../../../lib/types";
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
describe("chatLines", () => {
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
const lines = chatLines([
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
msg({
type: "assistant",
content: [
{ type: "thinking", text: "hmm" },
{ type: "tool_use", name: "Read" },
],
}),
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
msg({
type: "assistant",
content: [
{ type: "tool_use", name: "Read" },
{ type: "text", text: "hello!" },
],
}),
]);
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
"hi",
["Read", "Read"],
"hello!",
]);
});
it("drops Claude Code's own injected lines", () => {
const lines = chatLines([
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
]);
expect(lines).toEqual([]);
});
});
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
act(() => vi.advanceTimersByTime(50));
act(() => vi.advanceTimersByTime(110));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
act(() => vi.advanceTimersByTime(10));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
});
it("holds a loop's last frame before starting over", () => {
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
render(
<PetSprite
id="cp-goku"
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
/>
);
const sprite = screen.getByTestId("pet-sprite");
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(1000));
act(() => vi.advanceTimersByTime(310));
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(400));
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
expect(sprite.style.backgroundPosition).toBe("0px 0px");
});
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
act(() => vi.advanceTimersByTime(3 * 840 - 1));
expect(sprite.dataset.row).toBe("4");
act(() => vi.advanceTimersByTime(1));
expect(sprite.dataset.row).toBe("0");
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
act(() => vi.advanceTimersByTime(10_000));
expect(sprite.dataset.row).toBe("7");
});
it("looks toward the pointer on a v2 sheet, only while idle", () => {
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
vi.stubGlobal(
"Image",
class {
naturalHeight = 2288;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
this.onload?.();
}
}
);
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
// Pose 12 (270°, left) = row 10, column 4.
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
expect(sprite.dataset.row).toBe("7");
vi.unstubAllGlobals();
});
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
gaze={4}
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
@@ -208,7 +261,7 @@ describe("PetMenu", () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
});
@@ -231,7 +284,7 @@ describe("PetMenu", () => {
});
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
);
expect(api.pets.upload).toHaveBeenCalledWith(file);
});
@@ -260,11 +313,11 @@ describe("PetSettings", () => {
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image", name: "My cat" });
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
@@ -276,7 +329,7 @@ describe("PetSettings", () => {
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
);
});
@@ -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,16 +89,114 @@ describe("Sagi widget", () => {
expect(within(btn).getByText("1")).toBeInTheDocument();
});
it("reflects the live count in the panel status", () => {
it("names the errored session in its bubble and in the chat", () => {
renderSagi();
act(() => {
eventBus.publish({
type: "session_updated",
data: { id: "a", status: "error", name: "Fix login", cwd: "/w/app", lane_id: 3 } as Session,
timestamp: "t",
});
});
const bubble = screen.getByRole("status");
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: /chat with sagi/i });
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
});
it("stays quiet about a cleared error when that session updates again", () => {
renderSagi();
act(() => {
eventBus.publish(sessionMsg("a", "error"));
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: "Clear alerts" }));
act(() => {
eventBus.publish(sessionMsg("a", "error"));
});
const btn = screen.getByRole("button", { name: /close sagi/i, expanded: true });
expect(within(btn).queryByText("1")).not.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
});
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: /chat with mochi/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
});
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 chat = screen.getByRole("dialog", { name: /chat with 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(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
ensure.mockRestore();
});
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 }));
const pet = screen.getByRole("button", { name: /close sagi/i, expanded: true });
expect(pet.querySelector(".sagi-reaction-text")?.textContent).toBeTruthy();
});
it("stroking the pet back and forth pats it", () => {
renderSagi();
const pet = screen.getByRole("button", { name: /open sagi companion/i });
const xs = [100, 130, 100, 130, 100, 130, 100];
act(() => {
for (const x of xs) fireEvent.pointerMove(pet, { clientX: x, clientY: 50, buttons: 0 });
});
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
});
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();
});
@@ -107,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(() => {
@@ -115,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", () => {
@@ -144,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();
});
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
expect(statusOf(s).liveCount).toBe(1);
expect(s.worriedUntil).toBe(0);
});
it("keeps a cleared session quiet while it stays errored", () => {
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
s = clearErrors(s);
const again = reduceSagi(s, sessionMsg("b", "error", T0 + 1), T0 + 1);
expect(again.pulse).toBeNull();
expect(statusOf(again.state).errorCount).toBe(0);
expect(again.state.worriedUntil).toBe(0);
const ev = reduceSagi(
again.state,
{ type: "new_event", data: { session_id: "b", event_type: "toolError" }, timestamp: "" } as WSMessage,
T0 + 2
);
expect(ev.pulse).toBeNull();
expect(seedSessions(s, [{ id: "b", status: "error" }], T0).sessions.b).toBeUndefined();
});
it("alerts again once a cleared session recovers and fails anew", () => {
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
s = clearErrors(s);
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
expect(s.dismissed).not.toHaveProperty("b");
const r = reduceSagi(s, sessionMsg("b", "error"), T0);
expect(r.pulse).toBe("error");
});
});
describe("error alerts fire on the transition only", () => {
it("does not re-alert on each update of a still-errored session", () => {
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "error"), T0));
const r = reduceSagi(s, sessionMsg("a", "error"), T0 + 10_000);
expect(r.pulse).toBeNull();
expect(statusOf(r.state).errorCount).toBe(1);
});
});
// Reference the imported constant so it is exercised and tsc-clean.
@@ -75,4 +75,27 @@ describe("matchIntent", () => {
const r = matchIntent("Refactor my auth module", status());
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
});
it("understands Vietnamese keywords", () => {
const r = matchIntent("có lỗi nào không?", status({ errorCount: 2 }));
if (r.kind === "answer") expect(r.text).toContain("2 sessions errored");
expect(matchIntent("đang chạy gì vậy", status({ liveCount: 1 })).kind).toBe("answer");
expect(matchIntent("tình hình sao rồi", status()).kind).toBe("answer");
});
it("names the sessions it reports when it knows them", () => {
const r = matchIntent("any errors?", status({ errorCount: 1 }), {
attention: [{ id: "a", kind: "error", name: "Fix login", cwd: null, lane_id: null }],
});
if (r.kind === "answer") expect(r.text).toContain("errored: Fix login");
});
it("introduces itself by the pet's name and reacts to a pat", () => {
const who = matchIntent("who are you", status(), { name: "Mochi" });
expect(who).toMatchObject({ kind: "answer", moment: "wave" });
if (who.kind === "answer") expect(who.text).toContain("Mochi");
expect(matchIntent("pat", status())).toMatchObject({ kind: "answer", moment: "jump" });
// "pat" inside another word is not a pat.
expect(matchIntent("update the path", status()).kind).toBe("handoff");
});
});
+147 -30
View File
@@ -137,14 +137,7 @@ import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
export type Mood =
| "disconnected"
| "worried"
| "stuck"
| "happy"
| "thinking"
| "watching"
| "sleeping"
| "idle";
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
/**
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
@@ -152,13 +145,21 @@ export type Mood =
* was irrelevant or non-notable.
*/
export type SagiPulse =
| "session_done"
| "session_start"
| "subagent_spawn"
| "waiting"
| "error"
| "run_done"
| null;
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
/** A short animation the pet plays on an event or a pat, over its mood. */
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
/** Which moment each pulse plays: greet a new session, hop at a finish,
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
session_start: "wave",
session_done: "jump",
run_done: "jump",
subagent_spawn: "review",
waiting: "wait",
error: "fail",
};
export interface SagiStatus {
/** Active + waiting sessions (everything not finished/errored). */
@@ -182,6 +183,47 @@ export interface SagiState {
worriedUntil: number;
/** True while an Ask request is in flight (panel). */
thinking: boolean;
/** Errored sessions the user cleared ("clear alerts"). Their further
* updates, agent errors and failure events stay silent until the session
* goes active again or ends — otherwise every update of a still-errored
* session re-raised the alert the user had just dismissed. */
dismissed: Record<string, true>;
/** Name and owning workspace of each tracked session, so a bubble or the
* panel can say WHICH session it means and open it. */
info: Record<string, SessionInfo>;
}
/** What Sagi knows about a session beyond its status. */
export interface SessionInfo {
name: string | null;
cwd: string | null;
lane_id: number | null;
}
/** The session a pulse is about, when it is about one. */
export interface SagiSubject extends SessionInfo {
id: string;
}
/** A session that needs the user: errored, or waiting on their input. */
export interface AttentionItem extends SagiSubject {
kind: "error" | "waiting";
}
const infoOf = (s: {
name?: string | null;
cwd?: string | null;
lane_id?: number | null;
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
/** A session's label for a bubble: its name, else its folder, else a short id. */
export function subjectLabel(s: SagiSubject): string {
if (s.name && s.name.trim()) return s.name.trim();
const base = s.cwd
?.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.pop();
return base || s.id.slice(0, 8);
}
// Tunable timing constants (ms).
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
happyUntil: 0,
worriedUntil: 0,
thinking: false,
dismissed: {},
info: {},
};
}
@@ -256,16 +300,28 @@ export function reduceSagi(
state: SagiState,
msg: WSMessage,
now: number
): { state: SagiState; pulse: SagiPulse } {
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
// The tracked session an agent/event message belongs to, when known.
const known = (id: string | undefined): SagiSubject | undefined =>
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
switch (msg.type) {
case "session_created":
case "session_updated": {
const s = msg.data as Session;
if (!s || !s.id) return { state, pulse: null };
const sessions = { ...state.sessions };
const info = { ...state.info, [s.id]: infoOf(s) };
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
let dismissed = state.dismissed;
let pulse: SagiPulse = null;
let happyUntil = state.happyUntil;
let worriedUntil = state.worriedUntil;
// Anything but a (still) errored status ends a dismissal: a later error
// of that session is a new one and must alert again.
if (s.status !== "error" && s.id in dismissed) {
dismissed = { ...dismissed };
delete dismissed[s.id];
}
if (s.status === "active") {
// "waiting" = active session blocked on user input (permission prompt
@@ -280,12 +336,20 @@ export function reduceSagi(
if (prev === undefined) pulse = "session_start";
}
} else if (s.status === "error") {
if (s.id in dismissed) {
return { state: { ...state, lastActivityAt: now }, pulse: null };
}
// Alert on the transition INTO error only; a session that stays
// errored keeps getting updates and must not re-alert on each one.
if (sessions[s.id] !== "error") {
worriedUntil = now + WORRIED_MS;
pulse = "error";
}
sessions[s.id] = "error";
worriedUntil = now + WORRIED_MS;
pulse = "error";
} else if (s.status === "completed" || s.status === "abandoned") {
const wasTracked = s.id in sessions;
delete sessions[s.id];
delete info[s.id];
if (s.status === "completed") {
happyUntil = now + HAPPY_MS;
if (wasTracked) pulse = "session_done";
@@ -293,31 +357,46 @@ export function reduceSagi(
}
return {
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
state: {
...state,
sessions,
dismissed,
info,
happyUntil,
worriedUntil,
lastActivityAt: now,
},
pulse,
subject,
};
}
case "agent_created": {
const a = msg.data as Agent;
if (a && a.status === "error") {
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
return {
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
pulse: "error",
subject: known(a.session_id),
};
}
// A freshly spawned subagent is worth announcing; the main agent landing
// is already covered by session_start.
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
return { state: { ...state, lastActivityAt: now }, pulse };
return {
state: { ...state, lastActivityAt: now },
pulse,
subject: pulse ? known(a.session_id) : undefined,
};
}
case "agent_updated": {
const a = msg.data as Agent;
if (a && a.status === "error") {
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
return {
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
pulse: "error",
subject: known(a.session_id),
};
}
return { state: { ...state, lastActivityAt: now }, pulse: null };
@@ -325,7 +404,8 @@ export function reduceSagi(
case "new_event": {
const e = msg.data as DashboardEvent;
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
const isFailure =
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
return {
state: {
...state,
@@ -333,6 +413,7 @@ export function reduceSagi(
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
},
pulse: isFailure ? "error" : null,
subject: isFailure ? known(e.session_id) : undefined,
};
}
@@ -361,24 +442,60 @@ export function reduceSagi(
*/
export function seedSessions(
state: SagiState,
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
rows: ReadonlyArray<{
id: string;
status: string;
awaiting_input_since?: string | null;
name?: string | null;
cwd?: string | null;
lane_id?: number | null;
}>,
now: number
): SagiState {
const sessions = { ...state.sessions };
const info = { ...state.info };
for (const r of rows) {
if (!r || !r.id) continue;
if (r.status === "error") sessions[r.id] = "error";
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
// completed / abandoned: leave untracked.
if (r.status === "error") {
if (r.id in state.dismissed) continue;
sessions[r.id] = "error";
} else if (r.status === "active") {
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
} else continue; // completed / abandoned: leave untracked.
info[r.id] = infoOf(r);
}
return { ...state, sessions, lastActivityAt: now };
return { ...state, sessions, info, lastActivityAt: now };
}
/** Drop all errored sessions from tracking (used by "clear alerts"). */
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
export function attentionOf(state: SagiState): AttentionItem[] {
const out: AttentionItem[] = [];
for (const kind of ["error", "waiting"] as const) {
for (const [id, s] of Object.entries(state.sessions)) {
if (s !== kind) continue;
out.push({
id,
kind,
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
});
}
}
return out;
}
/** Drop all errored sessions from tracking (used by "clear alerts") and
* remember them as dismissed, so their next update does not bring the alert
* straight back. */
export function clearErrors(state: SagiState): SagiState {
const sessions: SagiState["sessions"] = {};
const dismissed = { ...state.dismissed };
const info = { ...state.info };
for (const [id, s] of Object.entries(state.sessions)) {
if (s !== "error") sessions[id] = s;
else {
dismissed[id] = true;
delete info[id];
}
}
return { ...state, sessions, worriedUntil: 0 };
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
}
+124 -33
View File
@@ -3,7 +3,9 @@
* @description Sagi's local "Ask" brain. Matches a free-text question against a
* small set of intents answerable from cached dashboard status. Anything it
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
* Pure function - no network, no DOM - so it's fully unit-testable.
* Understands English and Vietnamese keywords, names the sessions it reports
* when it knows them, and answers in the UI language (`settings:sagi.intent`).
* No network, no DOM - so it's fully unit-testable.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -28,6 +30,8 @@
*
* ## Public surface
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
* - `AskContext` — exported API; see TSDoc on the symbol for behavior.
* - `statusSummary` — exported API; see TSDoc on the symbol for behavior.
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
@@ -54,70 +58,157 @@
*
* ----------------------------------------------------------------------------- */
import type { SagiStatus } from "./brain";
import i18n from "../../i18n";
import { subjectLabel, type AttentionItem, type PetMoment, type SagiStatus } from "./brain";
import { pickLine } from "./quips";
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
export type AskResult =
{ kind: "answer"; text: string; moment?: PetMoment } | { kind: "handoff"; prompt: string };
const plural = (n: number) => (n === 1 ? "" : "s");
/** What the answer may mention beyond the counts. */
export interface AskContext {
/** The pet's shown name. */
name?: string;
/** Errored and waiting sessions, named (`attentionOf`). */
attention?: ReadonlyArray<AttentionItem>;
}
export function matchIntent(query: string, status: SagiStatus): AskResult {
const q = query.trim().toLowerCase();
if (!q) {
return {
kind: "answer",
text: "ask me about your sessions - what's running, any errors, or a quick status.",
};
}
const t = (key: string, vars?: Record<string, unknown>) =>
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
const names = (items ?? []).filter((i) => i.kind === kind).map(subjectLabel);
if (names.length === 0) return "";
const shown = names.slice(0, 3).join(", ");
return `: ${names.length > 3 ? `${shown}…` : shown}`;
}
/** "2 sessions live, 1 waiting on you" — the one-line status a greeting carries. */
export function statusSummary(status: SagiStatus): string {
const parts = [i18n.t("settings:sagi.greet.summary", { count: status.liveCount })];
if (status.waitingCount > 0)
parts.push(i18n.t("settings:sagi.greet.summaryWaiting", { count: status.waitingCount }));
if (status.errorCount > 0)
parts.push(i18n.t("settings:sagi.greet.summaryErrors", { count: status.errorCount }));
return parts.join(", ");
}
export function matchIntent(query: string, status: SagiStatus, ctx: AskContext = {}): AskResult {
const q = query.trim().toLowerCase().normalize("NFC");
const name = ctx.name || "Sagi";
if (!q) return { kind: "answer", text: t("empty") };
const has = (...words: string[]) => words.some((w) => q.includes(w));
// Whole-word match, for short words that hide inside longer ones ("hi" in "this").
const word = (...words: string[]) =>
words.some((w) => new RegExp(`(^|[^\\p{L}])${w}($|[^\\p{L}])`, "u").test(q));
if (has("help", "what can you", "what do you do")) {
return {
kind: "answer",
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
};
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
return { kind: "answer", text: t("help", { name }) };
}
if (has("who are you", "your name", "bạn là ai", "tên gì", "tên bạn", "là ai")) {
return { kind: "answer", text: t("whoami", { name }), moment: "wave" };
}
if (has("thank", "thx", "cảm ơn", "cám ơn") || word("ty", "tks")) {
return { kind: "answer", text: pickLine("intent.thanks"), moment: "jump" };
}
if (
has("good boy", "good girl", "cute", "ngoan", "dễ thương", "xoa đầu", "vuốt ve", "giỏi quá") ||
word("pat")
) {
return { kind: "answer", text: pickLine("pat"), moment: "jump" };
}
// Errors first: "any failed runs" should report errors, not live count.
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
if (
has("error", "broke", "broken", "fail", "wrong", "crash", "lỗi", "hỏng", "thất bại", "sự cố")
) {
return {
kind: "answer",
text:
status.errorCount > 0
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
: "no errors - all clean 🐾",
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
: t("errorsNone"),
};
}
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
if (
has(
"waiting",
"stuck",
"blocked",
"input",
"my turn",
"paused",
"chờ",
"đợi",
"kẹt",
"tới lượt",
"đến lượt"
)
) {
return {
kind: "answer",
text:
status.waitingCount > 0
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
: "nothing's waiting on you right now 🐾",
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
: t("waitingNone"),
};
}
if (has("running", "active", "live", "going on", "happening", "in progress")) {
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
if (
has(
"running",
"active",
"live",
"going on",
"happening",
"in progress",
"đang chạy",
"đang làm",
"hoạt động"
)
) {
const tail = status.waitingCount > 0 ? t("liveTail", { count: status.waitingCount }) : "";
return {
kind: "answer",
text:
status.liveCount > 0
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
: "nothing's running right now - all quiet.",
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
};
}
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
if (
has(
"status",
"summary",
"overview",
"how are things",
"how's it",
"how is it",
"tình hình",
"trạng thái",
"tổng quan",
"thế nào",
"sao rồi"
)
) {
return {
kind: "answer",
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
status.connected ? "connected" : "offline"
}.`,
text: t("status", {
live: status.liveCount,
waiting: status.waitingCount,
errors: status.errorCount,
conn: t(status.connected ? "connected" : "offline"),
}),
};
}
if (word("hi", "hello", "hey", "yo", "chào", "xin chào", "alo")) {
return { kind: "answer", text: t("hello", { summary: statusSummary(status) }), moment: "wave" };
}
return { kind: "handoff", prompt: query.trim() };
}
+63 -3
View File
@@ -56,12 +56,19 @@ const MUTED_KEY = "agent-dashboard-sagi-muted";
const POS_KEY = "agent-dashboard-sagi-pos";
const PET_KEY = "agenthub.pet";
const SIZE_KEY = "agenthub.pet.size";
const DISMISSED_KEY = "agenthub.pet.dismissedErrors";
/** Per-pet nicknames the user typed, `{ [petId]: name }`. */
const NAMES_KEY = "agenthub.pet.names";
export const PET_NAME_MAX = 40;
/** Most recent dismissed error sessions kept across reloads. */
const DISMISSED_MAX = 200;
/** Sagi's own size, and the slider's range. */
export const PET_SIZE = { default: 68, min: 48, max: 250 };
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
* The kind rides along so the mascot renders without a fetch. */
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
* The kind (and its display name) ride along so the mascot renders, and
* introduces itself, without a fetch. */
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
const SAGI: PetChoice = { id: "sagi" };
function readPet(): PetChoice {
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
id: string;
kind: string;
name: string;
}> | null;
if (
p &&
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
p.id !== "sagi" &&
(p.kind === "codex" || p.kind === "image")
)
return { id: p.id, kind: p.kind };
return typeof p.name === "string" && p.name
? { id: p.id, kind: p.kind, name: p.name.slice(0, PET_NAME_MAX) }
: { id: p.id, kind: p.kind };
} catch {
/* unreadable: Sagi */
}
return SAGI;
}
function readNames(): Record<string, string> {
try {
const raw = JSON.parse(localStorage.getItem(NAMES_KEY) ?? "{}") as unknown;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: Record<string, string> = {};
for (const [id, v] of Object.entries(raw)) if (typeof v === "string" && v) out[id] = v;
return out;
} catch {
return {};
}
}
/** The name the pet goes by: the user's nickname, else the pet's own, else Sagi. */
export function petName(pet: PetChoice): string {
const nick = readNames()[pet.id];
if (nick) return nick;
return ("name" in pet && pet.name) || "Sagi";
}
function readSize(): number {
const n = Number(localStorage.getItem(SIZE_KEY));
return Number.isFinite(n) && n > 0
@@ -181,6 +210,37 @@ export const sagiPrefs = {
SIZE_KEY,
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
),
/** Name a pet; an empty name gives it back its own. */
setNickname: (id: string, name: string) => {
const names = readNames();
const clean = name.replace(/\s+/g, " ").trim().slice(0, PET_NAME_MAX);
if (clean) names[id] = clean;
else delete names[id];
writeAndNotify(NAMES_KEY, JSON.stringify(names));
},
/** The user's nickname for a pet, "" when none. */
getNickname: (id: string) => readNames()[id] ?? "",
/** Errored session ids the user cleared, so a reload keeps them quiet. */
getDismissed: (): Record<string, true> => {
try {
const ids = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]");
const out: Record<string, true> = {};
if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out[id] = true;
return out;
} catch {
return {};
}
},
setDismissed: (dismissed: Record<string, true>) => {
try {
localStorage.setItem(
DISMISSED_KEY,
JSON.stringify(Object.keys(dismissed).slice(-DISMISSED_MAX))
);
} catch {
// Best-effort, like every pref here; no prefs event — widget-local state.
}
},
/** Subscribe to any pref change; returns an unsubscribe fn. */
subscribe(handler: () => void): () => void {
const listener = () => handler();
+52 -65
View File
@@ -1,8 +1,8 @@
/**
* @file quips.ts
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
* plus a deterministic-by-injection picker. Pure data + a pure function so it
* can be unit-tested without randomness leaking in.
* @description Sagi's personality: pools of short phrases keyed by pulse/mood
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
* so they follow the UI language, plus a deterministic-by-injection picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -24,10 +24,13 @@
*
* ## Internal dependencies
* - `./brain`
* - `../../i18n`
*
* ## Public surface
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
* - `pickLine` — exported API; see TSDoc on the symbol for behavior.
* - `greetingKey` — exported API; see TSDoc on the symbol for behavior.
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
@@ -59,76 +62,60 @@
*
* ----------------------------------------------------------------------------- */
import i18n from "../../i18n";
import type { Mood, SagiPulse } from "./brain";
export type QuipKey = NonNullable<SagiPulse> | Mood;
const QUIPS: Record<QuipKey, string[]> = {
export const ALL_QUIP_KEYS: QuipKey[] = [
// Pulses (event-driven, transient bubbles)
session_done: [
"a session just wrapped up! 🐾",
"a session finished - nice work! ✨",
"that session's all done 😺",
"clean finish on that one 💜",
],
session_start: [
"a new session started! 👀",
"a fresh session just landed 🐾",
"ooh, a new session to watch 😻",
"something new is cooking 🍲",
],
subagent_spawn: [
"a subagent just spawned! 🐾",
"a little helper joined in 🤝",
"a subagent's on the job 🚀",
"reinforcements - new subagent! 😺",
],
waiting: [
"a session needs your input 👀",
"a session is waiting on you ⏳",
"a session paused for your reply 💬",
"your turn - a session's waiting 🐾",
],
error: [
"uh oh, a session hit an error 😿",
"something broke - wanna peek? 🙀",
"a hook tripped on something ⚠️",
"hiss… an error popped up 💢",
],
run_done: [
"your run just finished! 🐾",
"the run's all wrapped up ✨",
"run complete - that's a wrap 😸",
"all done with that run 💜",
],
// Moods (steady-state flavor, used by the panel / idle bubbles)
disconnected: [
"lost the connection… 😴",
"can't reach the server 📡",
"no signal - taking a nap 💤",
],
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
stuck: [
"a session's been quiet a while… 🤔",
"is something stuck? ⏳",
"still chewing on it… 😾",
],
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
};
"session_done",
"session_start",
"subagent_spawn",
"waiting",
"error",
"run_done",
// Moods (steady-state flavor)
"disconnected",
"worried",
"stuck",
"happy",
"thinking",
"watching",
"sleeping",
"idle",
];
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
/**
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
* to Math.random. Returns "" only for an unknown key (never throws).
* Pick one line from an i18n string array under `settings:sagi.<path>`.
* `rand` is injectable for deterministic tests. Returns "" for a missing or
* empty pool (never throws).
*/
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
const pool = QUIPS[key];
if (!pool || pool.length === 0) return "";
export function pickLine(
path: string,
rand: () => number = Math.random,
vars?: Record<string, unknown>
): string {
const pool = i18n.t(`settings:sagi.${path}`, { returnObjects: true, ...vars }) as unknown;
if (!Array.isArray(pool) || pool.length === 0) return "";
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
return pool[i] ?? "";
const line = pool[i];
return typeof line === "string" ? line : "";
}
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
/**
* Pick a quip for a key, in the UI language. Returns "" only for an unknown
* key (never throws).
*/
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
return KNOWN.has(key) ? pickLine(`quips.${key}`, rand) : "";
}
/** Which greeting pool fits the hour: morning 5–11, afternoon 12–17, evening 18–22, else night. */
export function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" {
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 23) return "evening";
return "night";
}
+298 -11
View File
@@ -1,8 +1,12 @@
/**
* sagi.css - animations + mood expressions for the Sagi companion.
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
* with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
* forever: each plays a few cycles when the mood begins and then rests — an
* infinite animation makes the browser draw a new frame 60 times a second for
* as long as the page is open (the drop-shadow filter re-rendered each time),
* which made the whole desktop stutter. All motion is disabled under
* [data-reduced="1"] and prefers-reduced-motion.
* Transform origins are the delicate part - see the comments on .sagi-avatar
* and .sagi-eye-blink for which reference box each one resolves against.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -206,20 +210,20 @@
/* idle: gentle breathing + occasional blink */
.sagi-avatar[data-mood="idle"] {
animation: sagi-breathe 4s ease-in-out infinite;
animation: sagi-breathe 4s ease-in-out 0s 3;
}
/* Blink lives on the inner group so it never overrides the outer group's
eye-tracking translate (which would freeze the eyes - see the CSS-blink
note in SagiAvatar.tsx). */
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
animation: sagi-blink 5s ease-in-out infinite;
animation: sagi-blink 5s ease-in-out 0s 3;
}
/* happy: whole-mascot bob + twinkling sparkle */
.sagi-avatar[data-mood="happy"] {
animation: sagi-bob 0.5s ease-in-out 0s 4;
}
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
}
/* worried: alarmed shake */
@@ -228,19 +232,97 @@
}
/* stuck: slow, drooping breathe (fatigued) */
.sagi-avatar[data-mood="stuck"] {
animation: sagi-droop 2.4s ease-in-out infinite;
animation: sagi-droop 2.4s ease-in-out 0s 3;
}
/* thinking: rocking head tilt */
.sagi-avatar[data-mood="thinking"] {
animation: sagi-rock 1.8s ease-in-out infinite;
animation: sagi-rock 1.8s ease-in-out 0s 4;
}
/* watching: attentive sway while a session is live */
.sagi-avatar[data-mood="watching"] {
animation: sagi-sway 3.2s ease-in-out infinite;
animation: sagi-sway 3.2s ease-in-out 0s 3;
}
/* sleeping: slow breathe, droop */
.sagi-avatar[data-mood="sleeping"] {
animation: sagi-breathe 5s ease-in-out infinite;
animation: sagi-breathe 5s ease-in-out 0s 3;
}
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
Shared by Sagi's own art and an uploaded still image. ── */
.sagi-avatar[data-moment="jump"],
.sagi-pet-image[data-moment="jump"] {
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
}
.sagi-avatar[data-moment="wave"],
.sagi-pet-image[data-moment="wave"] {
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
}
.sagi-avatar[data-moment="fail"],
.sagi-pet-image[data-moment="fail"] {
animation: sagi-shake 0.35s ease-in-out 0s 5;
}
.sagi-avatar[data-moment="wait"],
.sagi-pet-image[data-moment="wait"] {
animation: sagi-tap 0.4s ease-in-out 0s 5;
}
.sagi-avatar[data-moment="review"],
.sagi-pet-image[data-moment="review"] {
animation: sagi-peek 2.2s ease-in-out 0s 1;
}
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
transform-origin: 797px 274px;
}
@keyframes sagi-hop {
0%,
100% {
transform: translateY(0) scale(1, 1);
}
15% {
transform: translateY(0) scale(1.08, 0.92);
}
45% {
transform: translateY(-14%) scale(0.96, 1.05);
}
75% {
transform: translateY(0) scale(1.04, 0.96);
}
}
@keyframes sagi-wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-9deg);
}
75% {
transform: rotate(9deg);
}
}
@keyframes sagi-tap {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4%);
}
}
@keyframes sagi-peek {
0%,
100% {
transform: rotate(0deg) translateX(0);
}
25%,
40% {
transform: rotate(-7deg) translateX(-4%);
}
60%,
75% {
transform: rotate(7deg) translateX(4%);
}
}
/* ── reduced motion: kill all continuous animation ── */
@@ -339,7 +421,15 @@
align-items: center;
justify-content: center;
border: 2px solid #0c0c14;
animation: sagi-error-pulse 1.2s ease-in-out infinite;
animation: sagi-error-pulse 1.2s ease-in-out 0s 5;
}
/* Sessions waiting on the user, when none errored: the same dot, amber. */
.sagi-error-dot[data-kind="waiting"] {
background: #f59e0b;
animation: none;
}
.sagi-pet-image {
transform-origin: 50% 92%;
}
.sagi-bubble {
max-width: 16rem;
@@ -356,6 +446,41 @@
.sagi-bubble-enter {
animation: sagi-bubble-in 0.22s ease-out;
}
.sagi-bubble[data-tone="error"] {
border-color: rgba(239, 68, 68, 0.6);
}
.sagi-bubble[data-tone="waiting"] {
border-color: rgba(245, 158, 11, 0.6);
}
/* The session(s) a bubble is about, and the button that opens it. */
.sagi-bubble-detail {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1rem;
color: #a8a8c0;
}
.sagi-bubble-subject {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sagi-bubble-open {
flex-shrink: 0;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: rgba(139, 92, 246, 0.2);
color: #c4b5fd;
font-weight: 500;
transition: background 0.15s;
}
.sagi-bubble-open:hover {
background: rgba(139, 92, 246, 0.35);
}
@keyframes sagi-bubble-in {
from {
opacity: 0;
@@ -375,7 +500,7 @@
/* An uploaded still image has no frames of its own: it bobs, faster while an
agent works and slower asleep. A Codex spritesheet animates by its frames. */
.sagi-pet-image {
animation: sagi-pet-bob 2.6s ease-in-out infinite;
animation: sagi-pet-bob 2.6s ease-in-out 0s 3;
-webkit-user-drag: none;
}
.sagi-pet-image[data-mood="thinking"] {
@@ -403,3 +528,165 @@
.sagi-flyout[data-front="1"] {
z-index: 42;
}
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
/* The reference's soft gradient, in the theme's own accent rather than a fixed
colour: a tint of the accent at the top fading into the surface, so it
reads in every light and dark theme. Replies sit on the plain surface like
the reference's white bubbles; the user's own are accent-solid (white text
is only ever on accent-solid). */
.pet-chat {
background: linear-gradient(
180deg,
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
rgb(var(--surface-1)) 100%
);
}
.pet-chat-head {
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
}
.pet-chat-reply {
background: rgb(var(--surface-0));
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
}
.pet-chat-tools {
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
}
.pet-chat-foot {
background: rgb(var(--surface-0));
border-top: 1px solid rgb(var(--border) / 0.6);
}
/* Three bouncing dots while a reply is written. */
.pet-chat-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: rgb(var(--accent));
opacity: 0.5;
animation: pet-chat-dot 1.2s ease-in-out infinite;
}
.pet-chat-dot:nth-child(2) {
animation-delay: 0.15s;
}
.pet-chat-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes pet-chat-dot {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-4px);
opacity: 0.9;
}
}
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
does not shrink the pane to fit it) and a non-zero box (so the paste
registry counts it as on screen). */
.pet-chat-term {
position: fixed;
left: -10000px;
top: 0;
width: 760px;
height: 460px;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.pet-chat-dot {
animation: none;
}
}
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
/* Hearts float up from the pet and its line shows beside it, inside the
avatar button — so a pat reads even while the panel or the chat is open
(the speech bubble hides then). */
.sagi-reaction {
position: absolute;
inset: 0;
pointer-events: none;
}
.sagi-hearts {
position: absolute;
left: 50%;
top: 10%;
display: flex;
gap: 2px;
transform: translateX(-50%);
font-size: 14px;
line-height: 1;
}
.sagi-hearts > span {
animation: sagi-heart-up 1.6s ease-out forwards;
opacity: 0;
}
.sagi-hearts > span:nth-child(2) {
animation-delay: 0.15s;
font-size: 18px;
}
.sagi-hearts > span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes sagi-heart-up {
0% {
opacity: 0;
transform: translateY(6px) scale(0.6);
}
20% {
opacity: 1;
transform: translateY(-4px) scale(1);
}
100% {
opacity: 0;
transform: translateY(-34px) scale(0.9);
}
}
/* The line sits on the side away from the screen edge the pet docks to. */
.sagi-reaction-text {
position: absolute;
top: 30%;
white-space: nowrap;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
background: rgb(var(--surface-0));
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
color: rgb(var(--fg-primary));
font-size: 0.75rem;
line-height: 1.1rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
animation: sagi-reaction-in 2.4s ease-out forwards;
}
.sagi-reaction-text[data-side="right"] {
right: calc(100% + 6px);
}
.sagi-reaction-text[data-side="left"] {
left: calc(100% + 6px);
}
@keyframes sagi-reaction-in {
0% {
opacity: 0;
transform: scale(0.85);
}
10%,
80% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.sagi-hearts > span,
.sagi-reaction-text {
animation: none;
opacity: 1;
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export function usePets() {
}
};
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind, name: p.displayName });
const install = (featuredId: string) =>
run(featuredId, async () => {
wear((await api.pets.installFeatured(featuredId)).pet);
+238 -37
View File
@@ -2,8 +2,12 @@
* @file useSagiBrain.ts
* @description React hook that wires the pure Sagi brain to the live event bus
* and to real timers. It is the only unit that subscribes to `eventBus`. It
* exposes the derived mood, a status summary, the current speech bubble, and
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
* exposes the derived mood, a status summary, the sessions that need the user,
* the current speech bubble (naming the session it is about, and coalescing a
* burst of the same event into one count), the pet's one-shot animation
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
* for the UI shell. It also greets once per browser session and makes an
* idle pet fidget now and then.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -62,124 +66,303 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { eventBus } from "../../lib/eventBus";
import { api } from "../../lib/api";
import i18n from "../../i18n";
import type { WSMessage } from "../../lib/types";
import {
initialSagiState,
reduceSagi,
deriveMood,
statusOf,
attentionOf,
clearErrors,
seedSessions,
subjectLabel,
MOMENT_OF_PULSE,
type AttentionItem,
type Mood,
type PetMoment,
type SagiPulse,
type SagiState,
type SagiStatus,
type SagiSubject,
} from "./brain";
import { pickQuip } from "./quips";
import { greetingKey, pickLine, pickQuip } from "./quips";
import { statusSummary } from "./intents";
import { sagiPrefs } from "./prefs";
/** `next` differs from `prev` only by a lastActivityAt less than a second
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
if (next === prev) return true;
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
for (const k of Object.keys(next) as (keyof SagiState)[]) {
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
}
return true;
}
const BUBBLE_MS = 4500;
// A bubble that names a session stays long enough to click it.
const SUBJECT_BUBBLE_MS = 7000;
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
const BUBBLE_THROTTLE_MS = 3000;
// The same pulse again within this window joins the bubble on show as a count.
const COALESCE_MS = 8000;
// How long a moment (wave, hop, flinch…) plays over the mood.
const MOMENT_MS = 2400;
// An idle pet fidgets (waves or looks around) once in a while.
const FIDGET_MIN_MS = 45_000;
const FIDGET_SPAN_MS = 60_000;
// Greet once per browser session, shortly after the snapshot lands.
const GREETED_KEY = "agenthub.pet.greeted";
const GREET_DELAY_MS = 1200;
/** What the speech bubble shows. */
export interface SagiBubble {
text: string;
/** Which session(s) it is about, shown under the text. */
detail?: string;
/** The one session it is about, which a click opens. */
subject?: SagiSubject;
/** Errors and waits are drawn with an accent. */
tone?: "error" | "waiting";
}
export interface SagiBrain {
mood: Mood;
status: SagiStatus;
bubble: string | null;
/** Errored, then waiting sessions, named. */
attention: AttentionItem[];
bubble: SagiBubble | null;
dismissBubble: () => void;
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
* errors). Bypasses the throttle because the caller decided this message
* must be seen right now. Auto-clears after the standard bubble TTL. */
setBubble: (text: string) => void;
/** The one-shot animation playing now, if any; `key` changes per moment so a
* repeat of the same one restarts it. */
moment: { kind: PetMoment; key: number } | null;
/** Play a moment by hand (an answer, a pat). */
playMoment: (kind: PetMoment) => void;
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
* even while the panel or chat is open, and while muted (it was asked). */
pat: () => void;
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
reaction: { text: string; key: number } | null;
muted: boolean;
toggleMute: () => void;
clearAlerts: () => void;
setThinking: (v: boolean) => void;
}
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
function burstBubble(b: Burst): SagiBubble {
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
const labels = b.subjects.map(subjectLabel);
const detail =
labels.length > 2
? i18n.t("settings:sagi.bubbleMore", {
list: labels.slice(0, 2).join(", "),
count: labels.length - 2,
})
: labels.join(", ") || undefined;
const text =
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
return { text, detail, subject: one, tone };
}
export function useSagiBrain(): SagiBrain {
const now0 = Date.now();
// Start optimistically connected (idle, open eyes) so the cursor-tracking
// eyes are live from the first frame instead of after the WebSocket finishes
// its initial handshake. onConnection below corrects this if it's truly down.
const [state, setState] = useState<SagiState>(() => ({
const [state, setStateRaw] = useState<SagiState>(() => ({
...initialSagiState(now0),
connected: true,
dismissed: sagiPrefs.getDismissed(),
}));
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
// so a pulse notifies exactly once; this ref is the state it folds into.
const stateRef = useRef(state);
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
const next = fn(stateRef.current);
if (next === stateRef.current) return;
stateRef.current = next;
setStateRaw(next);
}, []);
const [tick, setTick] = useState(now0);
const [bubble, setBubbleState] = useState<string | null>(null);
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
const lastBubbleAt = useRef(0);
const burst = useRef<Burst | null>(null);
const momentKey = useRef(0);
const mutedRef = useRef(muted);
mutedRef.current = muted;
// Keep cleared alerts cleared across reloads.
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
// Keep mute in sync with the Settings page / other tabs.
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
const showBubble = useCallback((text: string, force: boolean) => {
if (!text) return;
if (mutedRef.current) return;
const t = Date.now();
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
lastBubbleAt.current = t;
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
clearTimeout(bubbleTimer.current);
setBubbleState(text);
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
setBubbleState(b);
bubbleTimer.current = setTimeout(() => {
setBubbleState(null);
burst.current = null;
}, ms);
}, []);
const playMoment = useCallback((kind: PetMoment) => {
clearTimeout(momentTimer.current);
setMoment({ kind, key: ++momentKey.current });
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
}, []);
const notify = useCallback(
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
playMoment(MOMENT_OF_PULSE[pulse]);
if (mutedRef.current) return;
const t = Date.now();
const b = burst.current;
// The same kind of news again, while its bubble is still up: count it in.
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
b.count++;
b.at = t;
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
return;
}
const force = pulse === "error" || pulse === "waiting";
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
lastBubbleAt.current = t;
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
burst.current = next;
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
},
[playMoment, putBubble]
);
// Seed from the REST snapshot on mount so counts are accurate immediately -
// the brain otherwise only learns about sessions from WS deltas that arrive
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
// Pull a generous page of non-finished sessions; live WS deltas refine it.
// Then say hello, once per browser session.
useEffect(() => {
let cancelled = false;
let greetTimer: ReturnType<typeof setTimeout> | undefined;
const greet = () => {
try {
if (sessionStorage.getItem(GREETED_KEY)) return;
sessionStorage.setItem(GREETED_KEY, "1");
} catch {
return; // No storage: rather silent than a hello on every page.
}
greetTimer = setTimeout(() => {
if (cancelled || mutedRef.current) return;
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
if (!hello) return;
lastBubbleAt.current = Date.now();
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
playMoment("wave");
}, GREET_DELAY_MS);
};
api.sessions
.list({ status: "active", limit: 100 })
.then((res) => {
if (cancelled) return;
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
update((prev) => seedSessions(prev, res.sessions, Date.now()));
})
.catch(() => undefined);
.catch(() => undefined)
.finally(greet);
return () => {
cancelled = true;
clearTimeout(greetTimer);
};
}, []);
}, [update, putBubble, playMoment]);
// Subscribe to the live stream and connection status.
useEffect(() => {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
const t = Date.now();
setState((prev) => {
const { state: next, pulse } = reduceSagi(prev, msg, t);
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
return next;
});
const prev = stateRef.current;
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
// Most frames only move lastActivityAt, which the moods read at a 1 s
// resolution: keep the old state then, so a hook burst (several frames a
// second) does not re-render the mascot on every frame.
if (!onlyActivityMoved(prev, next)) update(() => next);
if (pulse) notify(pulse, subject);
});
const unsubConn = eventBus.onConnection((connected) => {
setState((prev) => ({ ...prev, connected }));
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
});
return () => {
unsubMsg();
unsubConn();
};
}, [showBubble]);
}, [update, notify]);
// Advance the clock so timed moods (stuck/sleeping, and exit from
// happy/worried) re-evaluate without needing a new event.
useEffect(() => {
const id = setInterval(() => setTick(Date.now()), 1000);
// Hidden tab: no re-render; the first visible tick catches the mood up.
// Only a tick that changes the mood re-renders: an idle mascot is not
// drawn again every second for nothing.
const id = setInterval(() => {
if (document.visibilityState !== "visible") return;
const now = Date.now();
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
}, 1000);
return () => clearInterval(id);
}, []);
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
useEffect(
() => () => {
clearTimeout(bubbleTimer.current);
clearTimeout(momentTimer.current);
clearTimeout(reactionTimer.current);
},
[]
);
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
// Judged at render time: `tick` only moves when the mood would change, so
// it may be old when a frame re-renders.
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
const status = useMemo(() => statusOf(state), [state]);
const attention = useMemo(() => attentionOf(state), [state]);
// An idle pet is not a statue: every minute or so it waves or looks around.
const moodRef = useRef(mood);
moodRef.current = mood;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const schedule = () => {
timer = setTimeout(
() => {
if (moodRef.current === "idle" && document.visibilityState === "visible")
playMoment(Math.random() < 0.5 ? "wave" : "review");
schedule();
},
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
);
};
schedule();
return () => clearTimeout(timer);
}, [playMoment]);
const dismissBubble = useCallback(() => {
clearTimeout(bubbleTimer.current);
setBubbleState(null);
burst.current = null;
}, []);
const toggleMute = useCallback(() => {
@@ -189,29 +372,47 @@ export function useSagiBrain(): SagiBrain {
if (next) dismissBubble();
}, [dismissBubble]);
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
const clearAlerts = useCallback(() => {
update((prev) => clearErrors(prev));
if (burst.current?.pulse === "error") dismissBubble();
}, [update, dismissBubble]);
const setThinking = useCallback(
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
[]
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
[update]
);
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
// imperative messages (e.g. spawn errors) that must appear now and fade,
// bypassing the throttled mood-driven showBubble path.
const setBubble = useCallback((text: string) => {
if (!text) return;
clearTimeout(bubbleTimer.current);
setBubbleState(text);
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
}, []);
const setBubble = useCallback(
(text: string) => {
if (!text) return;
burst.current = null;
putBubble({ text });
},
[putBubble]
);
const pat = useCallback(() => {
playMoment("jump");
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
clearTimeout(reactionTimer.current);
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
}, [playMoment, update]);
return {
mood,
status,
attention,
bubble,
dismissBubble,
setBubble,
moment,
playMoment,
pat,
reaction,
muted,
toggleMute,
clearAlerts,
+19 -4
View File
@@ -3,7 +3,8 @@
* @description Generic styled dropdown that replaces native `<select>` elements
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
* checkmark space inconsistently; this control aligns labels, flips above the
* trigger when viewport space is tight, and marks the chosen option with accent
* trigger when space is tight — in its split pane (`data-menu-bounds`, see
* `menuRoom`) or else the viewport — and marks the chosen option with accent
* color plus a Lucide check.
*
* ## Accessibility — WAI-ARIA "listbox button"
@@ -74,6 +75,14 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
* over anything crossing its edge — else the viewport. */
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
}
/** Single option in a {@link Select} list. */
export interface SelectOption<T extends string> {
/** Stored value emitted via `onChange`. */
@@ -84,6 +93,8 @@ export interface SelectOption<T extends string> {
hint?: string;
/** Optional leading icon, shown in the trigger and the list. */
icon?: ReactNode;
/** Draw a rule above this option: it starts a group of its own. */
divider?: boolean;
}
/** Props for {@link Select}. */
@@ -142,9 +153,11 @@ export function Select<T extends string>({
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const below = window.innerHeight - rect.bottom;
setOpenUp(below < 288 && rect.top > below);
const { above, below } = menuRoom(
buttonRef.current,
buttonRef.current.getBoundingClientRect()
);
setOpenUp(below < 288 && above > below);
}
listRef.current?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
@@ -272,6 +285,8 @@ export function Select<T extends string>({
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
opt.divider ? "mt-1 border-t border-border pt-2" : ""
} ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
+20 -4
View File
@@ -81,19 +81,22 @@ interface SessionOverviewProps {
}
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
const REFRESH_THROTTLE_MS = 1000;
const REFRESH_THROTTLE_MS = 5000;
/** Compact tile used in the top stat row. */
function StatTile({
label,
value,
hint,
help,
icon,
tone = "default",
}: {
label: string;
value: React.ReactNode;
hint?: string;
/** Plain-language explanation, shown as the label's tooltip. */
help?: string;
icon: React.ReactNode;
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) {
@@ -120,9 +123,14 @@ function StatTile({
return (
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
<div
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
title={help}
>
<span className={iconTone}>{icon}</span>
{label}
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
{label}
</span>
</div>
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
{value}
@@ -310,6 +318,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<StatTile
label={t("overview.events")}
help={t("common:insights.help.events")}
value={stats.total_events.toLocaleString()}
hint={
eventRate !== null && eventRate > 0
@@ -322,6 +331,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.toolCalls")}
help={t("common:insights.help.toolCalls")}
value={toolCallCount.toLocaleString()}
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
icon={<Wrench className="w-3 h-3" />}
@@ -329,6 +339,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.subagents")}
help={t("common:insights.help.subagents")}
value={stats.agents.subagent.toLocaleString()}
hint={
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
@@ -338,12 +349,14 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.compactions")}
help={t("common:insights.help.compactions")}
value={stats.agents.compaction.toLocaleString()}
icon={<Layers className="w-3 h-3" />}
tone="blue"
/>
<StatTile
label={t("overview.errors")}
help={t("common:insights.help.errors")}
value={stats.error_count.toLocaleString()}
icon={<AlertTriangle className="w-3 h-3" />}
tone={stats.error_count > 0 ? "rose" : "default"}
@@ -478,7 +491,10 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
{totalTokens > 0 && (
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-2.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<h3
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
title={t("common:insights.help.tokenFlow")}
>
<Coins className="w-3.5 h-3.5 text-status-warning" />
{t("overview.tokenFlow")}
</h3>
+107 -26
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -49,12 +49,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { NavLink } from "react-router-dom";
import { NavLink, useLocation } from "react-router-dom";
import { settingsHref } from "../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { useDialog } from "../hooks/useDialog";
import {
LayoutDashboard,
FolderOpen,
HardDrive,
Gem,
BarChart3,
Settings,
Wifi,
@@ -69,9 +72,14 @@ import {
ChevronUp,
ChevronDown,
FolderTree,
LogOut,
ShieldCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
import { useAuth, usePermissions } from "./AuthGate";
import { UserAvatar } from "./users/UserAvatar";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -83,15 +91,12 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
// The file manager (FilesPage): browse, upload and download workspace files.
{ to: "/files", icon: HardDrive, key: "nav:files" },
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
// owned by the brand, not vendored here — a machine with no network shows the
// alt text instead.
const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
@@ -188,6 +193,16 @@ export function Sidebar({
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
// link's own match (the path) cannot tell when it is the one on screen.
const location = useLocation();
const settingsOn =
location.pathname.startsWith("/settings") ||
new URLSearchParams(location.search).has("settings");
// Checking for (and applying) an update acts on the host: an admin's.
const perms = usePermissions();
const canUpdate = perms.isAdmin;
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -461,7 +476,16 @@ export function Sidebar({
user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
{[
// The file manager serves a shared server; locally it is the desktop's.
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
// Server mode: Administration (users, server), for an admin or analyst.
...(auth.state.mode === "server" &&
auth.state.user &&
auth.state.user.role !== "developer"
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
: []),
].map(({ to, icon: Icon, key }) => {
const label = t(key);
return (
<NavLink
@@ -482,6 +506,7 @@ export function Sidebar({
<button
type="button"
onClick={onToggle}
data-testid="sidebar-tree-rail"
title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
@@ -516,6 +541,59 @@ export function Sidebar({
)}
</div>
{/* Server mode: who is signed in — avatar, name and role, opening
Settings → Account — and Log out. On the rail they stack. */}
{auth.state.mode === "server" &&
auth.state.user &&
(() => {
const user = auth.state.user;
const name = user.displayName || user.username;
const role = t(`users:roles.${user.role}`);
const signedIn = t("common:auth.signedInAs", { name, role });
return (
<div
data-testid="sidebar-account"
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col items-center" : "items-center"
}`}
>
<NavLink
to={settingsHref("account", location)}
title={signedIn}
aria-label={signedIn}
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
}`}
>
<UserAvatar
name={name}
avatarAt={user.avatarAt}
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
/>
{!collapsed && (
<span className="min-w-0 leading-tight">
<span className="block truncate text-sm font-medium text-fg-primary">
{name}
</span>
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
</span>
)}
</NavLink>
<button
type="button"
onClick={() => void auth.logout()}
title={`${signedIn} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
</div>
);
})()}
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
@@ -524,9 +602,9 @@ export function Sidebar({
}`}
>
<NavLink
to="/settings"
to={settingsHref("", location)}
title={collapsed ? t("nav:settings") : undefined}
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
@@ -565,7 +643,7 @@ export function Sidebar({
checking,
available: updateAvailable,
failed: checkError,
onCheck: onCheckUpdates,
onCheck: canUpdate ? onCheckUpdates : undefined,
}}
onResetStats={() => {
eventCountRef.current = 0;
@@ -607,7 +685,8 @@ interface ConnectionStatusModalProps {
checking: boolean;
available: boolean;
failed: boolean;
onCheck: () => void;
/** Absent for a user who may not update (server mode, not an admin). */
onCheck?: () => void;
};
onResetStats: () => void;
}
@@ -749,18 +828,20 @@ function ConnectionStatusModal({
{update.title}
</p>
</div>
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
{update.onCheck && (
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
)}
</div>
</Section>
-19
View File
@@ -27,7 +27,6 @@
* ## Public surface
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
@@ -52,11 +51,6 @@
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TextSkeleton**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TableRowSkeleton**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
}
/**
* Single-line text placeholder. Good for trend/sub labels.
*/
export function TextSkeleton({
className = "",
width = "w-16",
}: {
className?: string;
width?: string;
}) {
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
}
/**
* Full table-row skeleton for tabular pages. Caller passes the column count so
* the skeleton lines up under existing headers.
+5 -84
View File
@@ -1,7 +1,7 @@
/**
* @file SplashScreen.tsx
* @description Branding splash shown once per browser session on app load. A
* dark-tech "constellation" overlay built around the node-graph brand mark:
* dark-tech "constellation" overlay built around the SmartGift brand mark:
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
* in a staggered cascade. The tagline and the subtext pair are picked at
* random (per mount) from localized pools in `splash.json`, so the copy is
@@ -143,7 +143,7 @@ export function SplashScreen() {
<div className="splash-grain" aria-hidden="true" />
<div className="splash-content">
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
{/* Brand mark - the SmartGift logo */}
<div className="splash-mark" aria-hidden="true">
<span className="splash-mark-glow" />
<BrandMark />
@@ -167,74 +167,10 @@ export function SplashScreen() {
);
}
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
* with animated connector lines and pulsing outer nodes. */
/** The SmartGift mark — the same file as the favicon, local so the splash
* needs no network. */
function BrandMark() {
return (
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
<defs>
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#818cf8" />
</linearGradient>
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
</linearGradient>
</defs>
<polygon
className="splash-hex"
points="16,2 28,9 28,23 16,30 4,23 4,9"
fill="url(#splashBg)"
/>
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
<line
className="splash-line"
x1="16"
y1="13"
x2="16"
y2="7"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-2"
x1="18.6"
y1="17.5"
x2="24"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-3"
x1="13.4"
y1="17.5"
x2="8"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
<circle
className="splash-node splash-node-2"
cx="24.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
<circle
className="splash-node splash-node-3"
cx="7.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
</svg>
);
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
}
/** Faint background constellation: a handful of nodes joined by thin lines that
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
animation: splashGlowPulse 2.6s ease-in-out infinite;
}
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
.splash-line-2 { animation-delay: 0.62s; }
.splash-line-3 { animation-delay: 0.74s; }
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
.splash-greeting {
display: flex;
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
.splash-star,
.splash-mark,
.splash-mark-glow,
.splash-hex,
.splash-line,
.splash-node,
.splash-dot,
.splash-greeting,
.splash-tagline,
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
opacity: 1 !important;
stroke-dashoffset: 0 !important;
transform: none !important;
}
}
+7 -1
View File
@@ -77,6 +77,8 @@ interface StatCardProps {
loading?: boolean;
/** Drill-in: the whole card links here (e.g. the list the number counts). */
to?: string;
/** Plain-language explanation of the metric, shown as the label's tooltip. */
help?: string;
}
/**
@@ -92,11 +94,15 @@ export function StatCard({
raw,
loading = false,
to,
help,
}: StatCardProps) {
const body = (
<>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
<span
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
title={help}
>
{label}
</span>
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
+99 -66
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 —
@@ -24,17 +28,32 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { 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", codex: "Codex" };
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
};
function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
@@ -121,6 +140,8 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
const QUOTA_ERROR_KEY: Record<string, string> = {
ELIVEEXPIRED: "statusBar.quotaExpired",
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
};
/** One account: its name, sign-in line, then a row per window with its reset. */
@@ -131,6 +152,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
<div data-testid="status-bar-quota-account" className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{q.label || q.account}</span>
{q.plan && (
<span
data-testid="status-bar-quota-plan"
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
>
{q.plan}
</span>
)}
{many && q.active !== false && (
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
{t("statusBar.quotaActive")}
@@ -143,12 +172,17 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
</div>
) : (
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
{q.windows.map((w) => {
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
{q.windows.map((w, i) => {
const p = Math.round(w.usedPercent ?? 0);
const resets = untilText(w.resetsAt);
return (
<Fragment key={w.label}>
<Fragment key={`${w.group ?? ""}-${w.label}`}>
{w.group && w.group !== q.windows[i - 1]?.group && (
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
{w.group}
</span>
)}
<span className="text-fg-muted">
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
</span>
@@ -159,16 +193,9 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
/>
</span>
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
<span className="flex items-center justify-end gap-1 text-fg-muted">
{resets && (
<>
<RotateCcw className="h-3 w-3" aria-hidden />
<span aria-hidden>{resets}</span>
<span className="sr-only">
{t("statusBar.quotaResets", { time: resets })}
</span>
</>
)}
{/* In words: a ↻ glyph here read as a Refresh button. */}
<span className="text-right text-fg-muted">
{resets && t("statusBar.quotaResets", { time: resets })}
</span>
</Fragment>
);
@@ -264,15 +291,15 @@ function QuotaChips() {
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
};
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback((force = false) => {
if (force) setRefreshing(true);
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined)
.finally(() => force && setRefreshing(false));
}, []);
const load = useCallback(
(force = false) =>
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined),
[]
);
const [refreshing, refresh] = useSpin(() => load(true));
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
if (!tools.length) return null;
return (
@@ -281,7 +308,7 @@ function QuotaChips() {
type="button"
data-testid="status-bar-quota"
data-tip-off
onClick={() => load(true)}
onClick={refresh}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
@@ -313,7 +340,7 @@ function QuotaChips() {
tools={tools}
onEnter={show}
onLeave={hide}
onRefresh={() => load(true)}
onRefresh={refresh}
refreshing={refreshing}
/>
)}
@@ -367,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"
}`}
/>
+102 -34
View File
@@ -16,14 +16,18 @@
* — and the poll waits for the old server to actually go DOWN first, because it
* keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it. Escape, the backdrop and "Not now" close it
* for this page load only (never while applying); "Skip this version" is the
* one persistent, per-version dismiss.
* background process can do for it. Escape, the backdrop and × close it for
* this page load only (never while applying); "Skip this version" is the one
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
* and runs the update command; the dashboard itself still decides nothing.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
* - Background re-check via `api.updates.check()` every hour
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
* - No client-side background re-check: the server checks on its own
* schedule and broadcasts `update_status`; "Check now" stays manual.
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
*
* ## Dismissal persistence
@@ -81,20 +85,30 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
Download,
X,
Copy,
Check,
RefreshCw,
Zap,
ExternalLink,
SkipForward,
Wrench,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { usePermissions } from "./AuthGate";
import { startInFreeTab } from "./lanes/startSession";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
/** How often to silently re-check for updates in the background. */
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
* go through a git fast-forward, and `plugin_managed` goes through the detached
@@ -109,6 +123,32 @@ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean
);
}
/** The prompt the Auto fix session starts with: what failed, where, and the
* limits that keep the agent from taking down the dashboard it runs under. */
function autoFixPrompt(status: UpdateStatusPayload, error: string): string {
const target = status.latest_release || status.remote_ref || "the newest version";
const plugin = status.situation === "plugin_managed";
return [
`The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`,
plugin
? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)."
: `The install is the git checkout at ${status.repo_root}.`,
"",
"Error:",
"```",
error,
"```",
...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []),
"",
"Find the cause, fix it, then finish the update" +
(plugin
? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)."
: " by running the update command."),
"Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).",
"When done, tell me what you changed; I will restart the dashboard from its update prompt.",
].join("\n");
}
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
@@ -128,10 +168,11 @@ function loadDismissedSha(): string | null {
* @returns `null` when no update is available or this release was dismissed.
*/
export function UpdateNotifier() {
const { isAdmin } = usePermissions();
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
// Closed for THIS page load only (Escape, backdrop, ×) — the
// persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
@@ -140,6 +181,8 @@ export function UpdateNotifier() {
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
setStatus(s);
@@ -172,19 +215,6 @@ export function UpdateNotifier() {
});
}, [syncFromPayload]);
// Background re-check every hour, on top of the initial mount fetch and the
// manual "Check now" button — so a long-lived tab notices an update without
// the user having to click anything.
useEffect(() => {
const id = setInterval(() => {
api.updates
.check()
.then(syncFromPayload)
.catch(() => {});
}, AUTO_CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, [syncFromPayload]);
useEffect(() => {
// An explicit "Check for updates" undoes both kinds of close.
const handler = () => {
@@ -206,13 +236,15 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
);
const busy = applying || restarting;
const busy = applying || restarting || fixing;
const closeForNow = useCallback(() => {
if (busy || !dismissKey) return;
@@ -329,6 +361,29 @@ export function UpdateNotifier() {
}
};
// Hand the failure to an AI agent: a workspace on the install folder (the
// home for a plugin install — never a lane inside the plugin cache) and a
// Claude Code session there with the error as its first prompt.
const autoFix = async () => {
if (busy || !status || !error) return;
setFixing(true);
try {
const plugin = status.situation === "plugin_managed";
const { lane } = await api.lanes.ensure({
...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }),
title: t("autoFixWorkspace"),
tool: "claude",
});
const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) });
if (dismissKey) setClosedKey(dismissKey);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : t("autoFixError"));
} finally {
setFixing(false);
}
};
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -475,7 +530,10 @@ export function UpdateNotifier() {
) : null}
{error ? (
<p className="text-xs text-status-danger" role="alert">
<p
className="text-xs text-status-danger whitespace-pre-wrap break-words max-h-48 overflow-auto"
role="alert"
>
{error}
</p>
) : null}
@@ -502,16 +560,9 @@ export function UpdateNotifier() {
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
<SkipForward className="w-3.5 h-3.5" aria-hidden />
{t("skipVersion")}
</button>
<button
type="button"
onClick={closeForNow}
disabled={busy}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("later")}
</button>
{status.manual_command ? (
<button
type="button"
@@ -523,11 +574,28 @@ export function UpdateNotifier() {
{copied ? t("copied") : t("copy")}
</button>
) : null}
{/* Only after a failed update: an AI agent takes the error from here. */}
{error ? (
<button
type="button"
onClick={autoFix}
disabled={busy}
title={t("autoFixHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{fixing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden />
) : (
<Wrench className="w-3.5 h-3.5" aria-hidden />
)}
{t("autoFix")}
</button>
) : null}
{isAutoApplicable(status.situation) ? (
<button
type="button"
onClick={applyNow}
disabled={applying || restarting}
disabled={busy}
className="btn-primary disabled:opacity-70"
>
{applying || restarting ? (
+172 -72
View File
@@ -6,21 +6,37 @@
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
* live tab is opened from its session row. Live status comes from the shared
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
* lane's status or session moves and handed over by the Workspace page's own
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`).
* lane's status or session moves (at most every 3 s) and handed over by the
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (their avatar —
* `owner_avatar_at`, `UserAvatar` — and `owner_display_name`, else `owner_name`; yours
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
import { useAuth, usePermissions } from "./AuthGate";
import { useAiTools } from "../hooks/useAiTools";
import { useThrottledValue } from "../hooks/useThrottledValue";
import {
insertionIndex,
moveBefore,
@@ -33,11 +49,22 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { AddLaneModal } from "./lanes/AddLaneModal";
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
import { UserAvatar } from "./users/UserAvatar";
// The sidebar is in the entry chunk; its two modals (and the run form the add
// modal pulls in) load when first opened.
const AddLaneModal = lazy(() =>
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
);
const DestructiveLaneModal = lazy(() =>
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
);
/** The workspace (and attached run) the Workspace page shows, from its URL. */
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
/** An owner as the tree shows it: their display name, else their username. */
const ownerShown = (l: Lane): string => l.owner_display_name || l.owner_name || "";
export function selectedFrom(pathname: string, search: string) {
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
const p = new URLSearchParams(search);
@@ -54,6 +81,11 @@ export function WorkspaceTree() {
const location = useLocation();
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes();
const { state: auth } = useAuth();
const { seesAll, canEditLane } = usePermissions();
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
// Server mode staff see every user's workspaces, so the list says whose.
const grouped = auth.mode === "server" && seesAll;
const prefs = useProjectTreePrefs();
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [query, setQuery] = useState("");
@@ -72,8 +104,12 @@ export function WorkspaceTree() {
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
// status or session moves, so that is when the live-pane list is re-read;
// the Workspace page also hands over every run list it polls (a tab in
// slots 1–3 moves no lane column).
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
// slots 1–3 moves no lane column). Throttled: with two sessions in one
// workspace these columns flip on every hook, and each flip was a GET.
const runKey = useThrottledValue(
lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(","),
3000
);
useEffect(() => {
let live = true;
api.run
@@ -111,10 +147,17 @@ export function WorkspaceTree() {
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
[lanes, order, toolFilter]
);
const sorted = useMemo(() => {
const list = sortByStoredOrder(lanes, order).filter(
(l) => !toolFilter || l.tool === toolFilter
);
if (!grouped) return list;
// Stable: each owner's workspaces keep the stored order inside the group.
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
return [...list].sort(
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
);
}, [lanes, order, toolFilter, grouped, meId]);
const q = query.trim().toLowerCase();
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -291,42 +334,92 @@ export function WorkspaceTree() {
)}
<div ref={listRef} className="space-y-px px-1">
{sorted.map((lane, i) => (
<div key={lane.id} className="relative">
{dropAt === i && <DropLine />}
{visible(lane) && (
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
))}
{sorted.map((lane, i) => {
const owner = ownerKey(lane);
// A search opens every group, like it opens every workspace.
const folded = grouped && !q && !!prefs.collapsedOwners[owner];
const readOnly = !canEditLane(lane);
const ownerLabel =
lane.owner_id == null
? t("tree.noOwner")
: lane.owner_id === meId
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
: ownerShown(lane) || `#${lane.owner_id}`;
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
return (
<div key={lane.id} className="relative">
{groupStart && groupShown && (
<button
type="button"
data-testid={`workspace-owner-${owner}`}
aria-expanded={!folded}
title={t("tree.ownerToggle", { name: ownerLabel })}
onClick={() =>
writeProjectTreePrefs({
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
})
}
className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
>
{folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<UserAvatar
name={ownerShown(lane) || "?"}
userId={lane.owner_id ?? undefined}
avatarAt={lane.owner_avatar_at}
letters={1}
className="h-4 w-4 text-[9px]"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
</span>
</button>
)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
<WorkspaceRow
lane={lane}
index={i}
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
// A read-only workspace has no home to open: it only unfolds.
if (readOnly) return setExpanded(lane.id, !expanded(lane.id));
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && !folded && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
readOnly={readOnly}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
);
})}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
@@ -349,32 +442,39 @@ export function WorkspaceTree() {
onAction={(a) => onMenuAction(menu.lane, a)}
/>
)}
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={(body) => void confirmDestructive(body)}
/>
)}
<AddLaneModal
open={addOpen}
cwdSuggestions={cwdSuggestions}
prefill={addPrefill}
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
}}
onAdded={(lane) => {
void refreshLanes();
select(lane.id);
}}
/>
<Suspense fallback={null}>
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={(body) => void confirmDestructive(body)}
/>
)}
{addOpen && (
<AddLaneModal
open
cwdSuggestions={cwdSuggestions}
prefill={addPrefill}
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
}}
onAdded={(lane) => {
void refreshLanes();
select(lane.id);
}}
/>
)}
</Suspense>
</section>
);
}
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
@@ -0,0 +1,60 @@
/**
* @file AlertsNotifications.roles.test.tsx
* @description Server mode, not an admin: alert rules and channels fire for
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
* Acknowledge only on alerts the server marks theirs (`can_ack`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const rulesList = vi.fn(async () => ({ rules: [] }));
const alert = (id: number, can_ack: boolean) => ({
id,
rule_id: "r1",
rule_name: `rule ${id}`,
rule_type: "error_rate",
session_id: null,
agent_id: null,
message: `boom ${id}`,
details: null,
triggered_at: "2026-10-08T00:00:00.000Z",
acknowledged_at: null,
can_ack,
});
vi.mock("../../lib/api", () => ({
api: {
alerts: {
rules: { list: () => rulesList() },
list: vi.fn(async () => ({
alerts: [alert(1, true), alert(2, false)],
total: 2,
unacked: 2,
})),
ack: vi.fn(),
ackAll: vi.fn(),
},
},
}));
vi.mock("../AuthGate", () => ({
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
}));
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications for a non-admin", () => {
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
expect(await screen.findByText("boom 2")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
expect(rulesList).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,191 @@
/**
* @file AuthGate.test.tsx
* @description The login gate: local mode renders the app untouched, server
* mode shows the login page until a login succeeds, a wrong password says so,
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
* analyst reads everything but works only in their own workspaces, a
* developer only ever sees (and so changes) their own. The login page wears the
* OS's light/dark; a sign-in swaps in the account's saved appearance.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
const me = vi.fn();
const login = vi.fn();
const prefs = vi.fn();
const LANES = [
{ id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 },
];
vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
},
}));
import { AuthGate, usePermissions } from "../AuthGate";
import { useWorkLanes } from "../../hooks/useLanes";
function Probe() {
const p = usePermissions();
const { lanes } = useWorkLanes();
return (
<p>
{[
`admin:${p.isAdmin}`,
`all:${p.seesAll}`,
`mine:${p.canEditLane({ owner_id: 5 })}`,
`theirs:${p.canEditLane({ owner_id: 6 })}`,
`work:${lanes.map((l) => l.id).join(",")}`,
].join(" ")}
</p>
);
}
const user = { id: 1, username: "mia", role: "developer" };
beforeEach(() => {
me.mockReset();
login.mockReset();
prefs.mockReset().mockResolvedValue({ prefs: {} });
localStorage.clear();
});
describe("AuthGate", () => {
it("renders the app in local mode", async () => {
me.mockResolvedValue({ mode: "local" });
render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
});
it("paints at once in a browser that last saw local mode", () => {
localStorage.setItem("agenthub.authMode", "local");
me.mockReturnValue(new Promise(() => {}));
render(<AuthGate>app</AuthGate>);
expect(screen.getByText("app")).toBeInTheDocument();
});
it("asks again instead of guessing when the server does not answer", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
me.mockRejectedValueOnce(new Error("502"));
me.mockResolvedValue({ mode: "server", user: null });
render(<AuthGate>app</AuthGate>);
expect(screen.queryByText("app")).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(2100);
});
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("renders the app when an older server or a dashboard token answers instead", async () => {
for (const err of [
{ status: 404 },
{ status: 200, code: "EBADRESPONSE" },
{ status: 401, code: "EUNAUTHORIZED" },
]) {
me.mockReset();
me.mockRejectedValue(Object.assign(new Error("x"), err));
const { unmount } = render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
unmount();
}
});
it("asks for a login in server mode and opens the app after one", async () => {
me.mockResolvedValue({ mode: "server", user: null });
login.mockRejectedValueOnce(Object.assign(new Error("x"), { code: "EBADLOGIN" }));
login.mockResolvedValueOnce({ user });
render(<AuthGate>app</AuthGate>);
const name = await screen.findByLabelText(/username/i);
const pass = screen.getByLabelText(/^password$/i);
await userEvent.type(name, "mia");
await userEvent.type(pass, "nope");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
await userEvent.type(pass, "member-pass-1");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText("app")).toBeInTheDocument();
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
act(() => {
window.dispatchEvent(new Event("agenthub:auth-required"));
});
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
});
});
describe("AuthGate theme", () => {
it("wears the OS theme while signed out and the account's own once signed in", async () => {
const html = document.documentElement;
// The previous user's choice, left in this browser.
localStorage.setItem("theme", "dark");
localStorage.setItem("agenthub.theme.dark", "darcula");
const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
vi.stubGlobal("matchMedia", () => mq); // the OS is light
me.mockResolvedValue({ mode: "server", user: null });
prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } });
login.mockResolvedValue({ user });
render(<AuthGate>app</AuthGate>);
await userEvent.type(await screen.findByLabelText(/username/i), "mia");
expect(html.dataset.mode).toBe("light");
expect(html.hasAttribute("data-theme")).toBe(false);
await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => expect(html.dataset.theme).toBe("orca-dark"));
expect(html.dataset.mode).toBe("dark");
expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark");
vi.unstubAllGlobals();
});
});
describe("usePermissions / useWorkLanes", () => {
const as = async (state: unknown) => {
cleanup();
me.mockResolvedValue(state);
render(
<AuthGate>
<Probe />
</AuthGate>
);
return (await screen.findByText(/work:\d/)).textContent;
};
it("lets an admin and local mode do everything", async () => {
for (const state of [
{ mode: "local" },
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
]) {
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
}
});
it("lets an analyst read everything but work only in their own workspaces", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
"admin:false all:true mine:true theirs:false work:1"
);
});
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
"admin:false all:false mine:true theirs:true work:1,2"
);
});
});
@@ -1,21 +1,21 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* path fills the field, a Cancel changes nothing, and a folder or a file falls
* back to the in-app browser on ANY native-picker failure (a LAN browser's
* EREMOTE included), where a double-click on a file picks it.
* @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 { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { PathPickButton } from "../PathPickButton";
vi.mock("../../lib/api", () => ({
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
}));
vi.mock("../../lib/toast", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/home/u/.ssh",
parent: "/home/u",
entries: [
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
],
});
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
await userEvent.click(screen.getByRole("button"));
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
expect(within(dialog).queryByText("Select this folder")).toBeNull();
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
expect(screen.queryByRole("dialog")).toBeNull();
expect(toast.info).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,8 @@
* @file Select.test.tsx
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
* listbox/option roles with aria-selected, aria-activedescendant tracking,
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
* Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
* list that flips up by the room in its split pane, not the window's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
fireEvent.click(screen.getByText("Apple"));
expect(onChange).toHaveBeenCalledWith("a");
});
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
render(
<div data-menu-bounds data-testid="pane">
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
</div>
);
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
const button = screen.getByRole("button", { name: "Fruit" });
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
vi.stubGlobal("innerHeight", 2000);
fireEvent.click(button);
expect(screen.getByRole("listbox").className).toContain("bottom-full");
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,73 @@
/**
* @file Sidebar.account.test.tsx
* @description The sidebar's account row (server mode): the signed-in user's
* avatar — or initials without one — name and role, linking to Settings →
* Account, beside Sign out; local mode shows no row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const auth = vi.hoisted(() => ({
state: { mode: "local" } as Record<string, unknown>,
logout: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock("../AuthGate", () => ({
useAuth: () => auth,
usePermissions: () => ({
isAdmin: auth.state.mode !== "server",
local: auth.state.mode !== "server",
seesAll: true,
canEditLane: () => true,
}),
}));
import { Sidebar } from "../Sidebar";
const renderSidebar = (collapsed = false) =>
render(
<MemoryRouter>
<Sidebar
wsConnected
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
describe("Sidebar account row", () => {
it("shows the avatar, name and role, linking to Settings → Account", () => {
auth.state = {
mode: "server",
user: { id: 2, username: "mia", role: "developer", displayName: "Mia Tran", avatarAt: "t1" },
};
renderSidebar();
const row = screen.getByTestId("sidebar-account");
expect(row.textContent).toContain("Mia Tran");
expect(row.textContent).toContain("Developer");
expect(within(row).getByTestId("user-avatar-img").getAttribute("src")).toBe(
"/api/me/avatar?v=t1"
);
expect(within(row).getAllByRole("link")[0]?.getAttribute("href")).toBe("/settings/account");
expect(within(row).getByRole("button", { name: "Sign out" })).toBeTruthy();
});
it("falls back to initials, and keeps only the avatar on the rail", () => {
auth.state = { mode: "server", user: { id: 3, username: "leo", role: "admin" } };
renderSidebar(true);
const row = screen.getByTestId("sidebar-account");
expect(within(row).getByTestId("user-avatar-initials").textContent).toBe("le");
expect(row.textContent).not.toContain("Admin");
});
it("has no account row in local mode", () => {
auth.state = { mode: "local" };
renderSidebar();
expect(screen.queryByTestId("sidebar-account")).toBeNull();
});
});
@@ -95,6 +95,8 @@ describe("Sidebar", () => {
expect(hrefs).toContain("/sessions");
expect(hrefs).toContain("/settings");
expect(hrefs).not.toContain("/activity");
// The file manager serves a shared server; a local dashboard has none.
expect(hrefs).not.toContain("/files");
});
describe("update status line, in the connection details modal", () => {
@@ -24,6 +24,7 @@ function today() {
vi.mock("../../lib/api", () => ({
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
sessions: {
// 3 active sessions, 1 of them waiting on the user.
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
active: true,
label: "Work",
account: "me@example.com",
plan: "max",
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
windows: [
{
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
windows: [],
error: "EUPSTREAM",
},
{
id: "antigravity",
active: true,
windows: [
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
],
},
{
id: "cursor",
active: true,
account: "c@example.com",
plan: "pro",
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
},
],
checkedAt: "",
}),
@@ -214,10 +232,10 @@ describe("StatusBar", () => {
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
await user.hover(chips);
const panel = screen.getByTestId("status-bar-quota-panel");
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workin use");
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workmaxin use");
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
expect(work).toHaveTextContent("5h95%41m");
expect(work).toHaveTextContent("5h95%Resets in 41m");
expect(work).toHaveTextContent("Week32%");
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired");
expect(panel).toHaveTextContent("Could not read the quota");
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
expect(chips).toHaveTextContent("Antigravity50% used");
expect(cursor).toHaveTextContent("c@example.compro");
expect(cursor).toHaveTextContent("Month40%");
expect(chips).toHaveTextContent("Cursor40% used");
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
await user.unhover(chips);
await user.hover(panel);
@@ -244,7 +267,11 @@ describe("StatusBar", () => {
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
await act(async () => answer(same));
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
// The spin outlasts an instant answer (a throttled refresh is served from
// cache), so the label returns after the minimum spin.
await waitFor(() =>
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent(/^Refresh$/)
);
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
await user.unhover(panel);
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
@@ -19,6 +19,18 @@ import type { UpdateStatusPayload } from "../../lib/types";
const statusMock = vi.fn();
const checkMock = vi.fn();
const applyMock = vi.fn();
const ensureMock = vi.fn();
const startMock = vi.fn();
const navigateMock = vi.fn();
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useNavigate: () => navigateMock,
}));
vi.mock("../lanes/startSession", () => ({
startInFreeTab: (...args: unknown[]) => startMock(...args),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -30,6 +42,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
check: (...args: unknown[]) => checkMock(...args),
apply: (...args: unknown[]) => applyMock(...args),
},
lanes: { ensure: (...args: unknown[]) => ensureMock(...args) },
},
};
});
@@ -56,6 +69,9 @@ beforeEach(() => {
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
applyMock.mockReset();
ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true });
startMock.mockReset().mockResolvedValue("agenthub-lane-7-1");
navigateMock.mockReset();
try {
localStorage.clear();
} catch {
@@ -274,7 +290,7 @@ describe("UpdateNotifier — closing vs skipping", () => {
it("Check for updates re-opens a modal closed with Later", async () => {
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
await userEvent.click(await screen.findByRole("button", { name: i18n.t("updates:later") }));
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
@@ -290,3 +306,26 @@ describe("UpdateNotifier — closing vs skipping", () => {
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
});
});
describe("UpdateNotifier — Auto fix", () => {
it("offers no Not now button, and Auto fix only once an update failed", async () => {
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:updateNow"));
expect(screen.queryByText(i18n.t("updates:later"))).not.toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:autoFix"))).not.toBeInTheDocument();
await userEvent.click(screen.getByText(i18n.t("updates:updateNow")));
await userEvent.click(await screen.findByText(i18n.t("updates:autoFix")));
await waitFor(() => expect(navigateMock).toHaveBeenCalled());
expect(ensureMock).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/repo", exact: true, tool: "claude" })
);
const [laneId, body] = startMock.mock.calls[0] ?? [];
expect(laneId).toBe(7);
expect(body.prompt).toContain("git merge --ff-only v2.8.0");
expect(body.prompt).toContain(BASE_STATUS.manual_command);
expect(navigateMock).toHaveBeenCalledWith("/workspace?lane=7&run=agenthub-lane-7-1");
});
});
@@ -0,0 +1,170 @@
/**
* @file WorkspaceTree.owners.test.tsx
* @description Server mode, the sidebar workspace list for an analyst: every
* user's workspaces, grouped under their owner (with their avatar, else an
* initial; yours first, no owner last),
* groups folding and remembered; another user's workspace is read only — no
* menu, no new session, and a session opens its analysis page, never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../lib/types";
const ME = { id: 2, username: "ana", role: "analyst" as const };
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
({
id,
title,
cwd: `/w/${id}`,
kind: "adopted",
tool: "claude",
status: "idle",
liveness: "idle",
pipeline_nodes: [],
owner_id: owner,
owner_name: name,
}) as unknown as Lane;
const lanes = [
{
...lane(1, 3, "bob", "Bob app"),
owner_display_name: "Bob Nguyen",
owner_avatar_at: "t1",
} as Lane,
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
];
vi.mock("../AuthGate", () => ({
useAuth: () => ({ state: { mode: "server", user: ME } }),
usePermissions: () => ({
isAdmin: false,
seesAll: true,
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
}),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
sessions: {
list: vi.fn(async () => ({
sessions: [
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
] as Session[],
total: 1,
limit: 6,
offset: 0,
})),
},
run: { list: vi.fn(async () => ({ items: [] })) },
users: {
avatarUrl: (id: number, at?: string | null) =>
at ? `/api/users/${id}/avatar?v=${at}` : null,
},
me: { avatarUrl: () => null },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
},
};
});
import { WorkspaceTree } from "../WorkspaceTree";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderTree() {
return render(
<MemoryRouter>
<Routes>
<Route
path="*"
element={
<>
<WorkspaceTree />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => localStorage.clear());
describe("WorkspaceTree for an analyst", () => {
it("groups every workspace under its owner: yours first, no owner last", async () => {
renderTree();
await screen.findByTestId("workspace-row-1");
const order = screen
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
.map((el) => el.dataset.testid);
expect(order).toEqual([
"workspace-owner-2",
"workspace-row-3",
"workspace-owner-1",
"workspace-row-4",
"workspace-owner-3",
"workspace-row-1",
"workspace-owner-none",
"workspace-row-2",
]);
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
});
it("names an owner by their display name when they set one", async () => {
renderTree();
await screen.findByTestId("workspace-row-1");
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
// Bob set a picture: shown beside the name; Ana did not: her initial.
expect(
within(screen.getByTestId("workspace-owner-3"))
.getByTestId("user-avatar-img")
.getAttribute("src")
).toBe("/api/users/3/avatar?v=t1");
expect(
within(screen.getByTestId("workspace-owner-2")).getByTestId("user-avatar-initials")
).toHaveTextContent("a");
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
});
it("folds a group and remembers it", async () => {
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-owner-3"));
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
"3": true,
});
});
it("keeps another user's workspace read only", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// Yours keeps its controls; theirs has none.
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
// Picking it unfolds it instead of opening a workspace home.
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
const list = await screen.findByTestId("workspace-sessions-1");
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
await user.click(within(list).getByTestId("workspace-session-s-1"));
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
});
});
@@ -81,8 +81,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
forgetHistory: vi.fn(async () => ({ removed: "" })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
@@ -156,7 +154,12 @@ describe("WorkspaceTree", () => {
expect(within(list).getByText("Live one")).toBeInTheDocument();
// Its OWN sessions only — never the rest of its folder.
expect(api.sessions.list).toHaveBeenCalledTimes(1);
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
expect(api.sessions.list).toHaveBeenCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 6,
});
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
"1": true,
});
@@ -177,9 +180,23 @@ describe("WorkspaceTree", () => {
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
await user.click(screen.getByTestId("workspace-sessions-more-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
expect(api.sessions.list).toHaveBeenLastCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 9,
});
await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
await user.click(screen.getByTestId("workspace-sessions-less-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 6,
});
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
});
it("renames a session on double-click + Enter, and archives one from its row", async () => {
@@ -195,6 +212,38 @@ describe("WorkspaceTree", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
it("pins a session above the live ones, and unpins it from its row or menu", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
session("s-live", "Live one"),
{ ...session("s-old", "Old one"), pinned_at: "2026-10-01T00:00:00Z" } as Session,
],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const list = await screen.findByTestId("workspace-sessions-1");
await screen.findByTestId("workspace-session-pinned-s-old");
expect(
within(list)
.getAllByTestId(/^workspace-session-s-/)
.map((el) => el.dataset.testid)
).toEqual(["workspace-session-s-old", "workspace-session-s-live"]);
await user.click(screen.getByTestId("workspace-session-pin-s-old"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { pinned: false });
await user.click(screen.getByTestId("workspace-session-pin-s-live"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { pinned: true });
await user.pointer({
keys: "[MouseRight]",
target: screen.getByTestId("workspace-session-s-old"),
});
await user.click(within(screen.getByTestId("session-menu-s-old")).getByText("Unpin"));
expect(api.sessions.patch).toHaveBeenLastCalledWith("s-old", { pinned: false });
});
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
const user = userEvent.setup();
renderTree();
@@ -205,7 +254,7 @@ describe("WorkspaceTree", () => {
let menu = screen.getByTestId("session-menu-s-old");
// Ended: no pane to split or close.
expect(within(menu).queryByText("Open in split")).toBeNull();
expect(within(menu).queryByText("Close tab (stops its session)")).toBeNull();
expect(within(menu).queryByText("Close tab (stops and archives its session)")).toBeNull();
await user.click(within(menu).getByText("Rename"));
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
@@ -222,11 +271,17 @@ describe("WorkspaceTree", () => {
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
await right(screen.getByTestId("workspace-session-s-live"));
await user.click(screen.getByTestId("session-menu-sleep"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: false });
await right(screen.getByTestId("workspace-session-s-live"));
await user.click(
within(screen.getByTestId("session-menu-s-live")).getByText("Close tab (stops its session)")
within(screen.getByTestId("session-menu-s-live")).getByText(
"Close tab (stops and archives its session)"
)
);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
});
it("archiving a worktree session removes its worktree first, after asking", async () => {
@@ -309,6 +364,23 @@ describe("WorkspaceTree", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("lists live sessions above ended ones, even an ended one active more recently", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [session("s-old", "Old one"), session("s-live", "Live one")],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await waitFor(() =>
expect(screen.getByTestId("workspace-session-s-live")).toHaveAttribute("data-live", "true")
);
const ids = screen.getAllByTestId(/^workspace-session-s-/).map((el) => el.dataset.testid);
expect(ids).toEqual(["workspace-session-s-live", "workspace-session-s-old"]);
});
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
vi.mocked(api.run.list).mockImplementation(
async () =>
@@ -351,97 +423,6 @@ describe("WorkspaceTree", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
});
it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => {
vi.mocked(api.run.history).mockImplementation(
async () =>
({
items: [
{
id: "agenthub-lane-1-2",
tool: "shell",
status: "running",
started_at: "2026-10-06T10:00:00Z",
ended_at: null,
isLive: true,
},
{
id: "agenthub-lane-1-3@a",
tool: "shell",
status: "killed",
started_at: "2026-10-06T09:00:00Z",
ended_at: "2026-10-06T09:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@b",
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@c",
tool: "shell",
status: "exited",
started_at: "2026-10-06T07:00:00Z",
ended_at: "2026-10-06T07:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@d",
tool: "shell",
status: "exited",
started_at: "2026-10-06T06:00:00Z",
ended_at: "2026-10-06T06:05:00Z",
isLive: false,
},
],
}) as never
);
vi.mocked(api.lanes.action).mockImplementation(
async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a");
expect(closed).toHaveTextContent("Terminal");
expect(api.run.history).toHaveBeenCalledWith(
expect.objectContaining({ laneId: 1, tool: "shell" })
);
// The live one is not history, and only the newest three are listed.
expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull();
expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3);
await user.click(closed);
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" });
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("removes a closed terminal from its hover X and its right-click menu", async () => {
const shell = (id: string) => ({
id,
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
});
vi.mocked(api.run.history).mockImplementation(
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
await user.click(screen.getByText("Remove from list"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
});
it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
+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>
);
}
@@ -0,0 +1,928 @@
/**
* @file ArtifactAddPanels.tsx
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
* uploaded file (server/lib/artifacts.js):
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
* file that becomes SKILL.md, several files, a whole folder — picked or
* dropped — or a .zip/.skill; an upload holding several skills makes one
* artifact each), **Write** (name, description, instructions) and
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
* .claude/skills). The user names the skill; the name is previewed as the
* folder and `/command` it installs as (`skillSlug`, the server's rule).
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
* web link, `owner/repo` or a marketplace.json link), read its plugin list
* 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).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
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 hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
export function skillSlug(text: string): string {
return text
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
/** `field:` of a Markdown file's frontmatter, or "". */
export function frontmatterField(text: string, name: string): string {
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
type Picked = { file: File; path: string };
/** A small file's text ("" on failure), only to prefill the name/description. */
function readText(file: File): Promise<string> {
if (file.size > 1024 * 1024) return Promise.resolve("");
return new Promise((ok) => {
const r = new FileReader();
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
r.onerror = () => ok("");
r.readAsText(file);
});
}
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
const items = Array.from(e.dataTransfer.items || []);
const entries = items
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
.filter((x): x is FileSystemEntry => !!x);
if (!entries.length) {
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
}
const out: Picked[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (entry.isFile) {
const file = await new Promise<File>((ok, fail) =>
(entry as FileSystemFileEntry).file(ok, fail)
);
out.push({ file, path: prefix + entry.name });
return;
}
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in batches until an empty one.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
reader.readEntries(ok, fail)
);
if (!batch.length) break;
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
}
};
for (const entry of entries) await walk(entry, "");
return out;
}
/** What an upload will make, guessed the way the server decides it. */
function describeUpload(files: Picked[]): {
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
count: number;
folder: string;
} {
const real = files.filter((f) => !JUNK.test(f.path));
if (!real.length) return { kind: "none", count: 0, folder: "" };
if (real.length === 1 && isArchive(real[0]!.path))
return { kind: "archive", count: 0, folder: "" };
const roots = [
...new Set(
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
),
].sort((a, b) => a.length - b.length);
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
if (!top.length) {
return real.length === 1 && isText(real[0]!.path)
? { kind: "single-md", count: 1, folder: "" }
: { kind: "no-skill-md", count: 0, folder: "" };
}
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
return { kind: "many", count: top.length, folder: "" };
}
function Tabs<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { id: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
{options.map((o) => (
<button
key={o.id}
type="button"
role="tab"
aria-selected={value === o.id}
onClick={() => onChange(o.id)}
className={`flex-1 rounded px-2 py-1 text-xs ${
value === o.id
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
value,
onChange,
disabled,
required,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
required?: boolean;
}) {
const { t } = useTranslation("artifacts");
const slug = skillSlug(value);
return (
<div className="space-y-0.5">
<label className={label}>
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
value={value}
disabled={disabled}
placeholder="release-notes"
onChange={(e) => onChange(e.target.value)}
/>
</label>
{value.trim() && (
<p className={hint} data-testid="skill-slug">
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
</p>
)}
</div>
);
}
// ── Skill ─────────────────────────────────────────────────────────────────
export function SkillAddPanel({
lanes,
onAdded,
onClose,
}: {
lanes: Lane[];
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation("artifacts");
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
const [busy, setBusy] = useState(false);
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [description, setDescription] = useState("");
const [descTouched, setDescTouched] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [files, setFiles] = useState<Picked[]>([]);
const [dragging, setDragging] = useState(false);
const [laneId, setLaneId] = useState<number | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
// `webkitdirectory` is not in React's typings.
useEffect(() => {
folderInput.current?.setAttribute("webkitdirectory", "");
}, [mode]);
useEffect(() => {
if (mode !== "existing") return;
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}, [mode, laneId]);
const shape = useMemo(() => describeUpload(files), [files]);
// Fill the name and description from what was picked, unless typed.
useEffect(() => {
if (!files.length) return;
const md =
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
let live = true;
void (async () => {
const text = md ? await readText(md.file) : "";
if (!live) return;
const fmName = frontmatterField(text, "name");
const fmDesc = frontmatterField(text, "description");
if (!nameTouched) {
const guess =
shape.kind === "single-md"
? fmName || stem(md?.path ?? "")
: shape.kind === "one"
? fmName || shape.folder
: shape.kind === "archive"
? stem(files[0]!.path)
: "";
setName(guess);
}
if (!descTouched && fmDesc) setDescription(fmDesc);
})();
return () => {
live = false;
};
// Refill only when the pick changes, never on a typed name.
}, [files, shape]);
const pick = (list: FileList | null) => {
if (!list?.length) return;
setFiles(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
}))
);
};
const run = async (fn: () => Promise<number>) => {
setBusy(true);
try {
const n = await fn();
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const many = shape.kind === "many";
const total = files.reduce((n, f) => n + f.file.size, 0);
const meta = (
<>
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
disabled={many}
placeholder={t("skill.descriptionHint")}
onChange={(e) => {
setDescTouched(true);
setDescription(e.target.value);
}}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
</>
);
return (
<div className="space-y-3">
<Tabs
value={mode}
onChange={setMode}
options={[
{ id: "upload", label: t("skill.tab.upload") },
{ id: "write", label: t("skill.tab.write") },
{ id: "existing", label: t("skill.tab.existing") },
]}
/>
{mode === "upload" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
const r = await api.artifacts.uploadSkills(files, {
name: many ? undefined : name.trim(),
description: many ? undefined : description.trim(),
title: many ? undefined : title.trim(),
});
return r.items.length;
});
}}
>
<div
data-testid="skill-drop"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void droppedFiles(e).then((list) => list.length && setFiles(list));
}}
className={`rounded-lg border border-dashed p-4 text-center ${
dragging ? "border-accent bg-accent/10" : "border-border"
}`}
>
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
<div className="mt-2 flex flex-wrap justify-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
onClick={() => fileInput.current?.click()}
>
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => folderInput.current?.click()}
>
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
</button>
</div>
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="skill-files"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
<input
ref={folderInput}
type="file"
multiple
hidden
data-testid="skill-folder"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
</div>
{files.length > 0 && (
<div className="rounded border border-border bg-surface-2 p-2">
<div className="mb-1 flex items-center gap-2">
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
</p>
<span className={hint}>
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
</span>
<button
type="button"
aria-label={t("skill.clear")}
className="text-fg-muted hover:text-fg-primary"
onClick={() => {
setFiles([]);
setNameTouched(false);
setDescTouched(false);
}}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
{files.slice(0, 200).map((f) => (
<li
key={f.path}
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
>
{f.path}
</li>
))}
</ul>
</div>
)}
{many ? (
<p className={hint}>{t("skill.manyHint")}</p>
) : (
<SkillNameField
value={name}
onChange={(v) => {
setNameTouched(true);
setName(v);
}}
required={shape.kind === "single-md"}
/>
)}
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!files.length ||
shape.kind === "no-skill-md" ||
(shape.kind === "single-md" && !skillSlug(name))
}
>
{busy ? t("skill.uploading") : t("form.save")}
</button>
</div>
</form>
)}
{mode === "write" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api.artifacts.writeSkill({
name: name.trim(),
description: description.trim(),
content,
title: title.trim() || undefined,
});
return 1;
});
}}
>
<SkillNameField value={name} onChange={setName} required />
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!skillSlug(name) ||
!content.trim() ||
(!description.trim() && !frontmatterField(content, "description"))
}
>
{t("form.save")}
</button>
</div>
</form>
)}
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("form.pickWorkspace")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(async () => {
await api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
});
return 1;
})
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
)}
</div>
);
}
// ── Plugin ────────────────────────────────────────────────────────────────
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
const { t } = useTranslation("artifacts");
const [link, setLink] = useState("");
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [have, setHave] = useState<Set<string>>(new Set());
const [manual, setManual] = useState(false);
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
api.artifacts
.list({ scope: "mine", kind: "plugin" })
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
.catch(() => setHave(new Set()));
}, []);
const read = async () => {
setBusy(true);
setError(null);
setInfo(null);
try {
const r = await api.artifacts.inspectMarket(link.trim());
setInfo(r);
setPicked(
new Set(
r.plugins
.filter((p) => !have.has(p.key))
.slice(0, 1)
.map((p) => p.key)
)
);
setMarket(r.market);
} catch (err) {
const e = err as Error & { code?: string };
setError(e.message);
if (MANUAL_CODES.has(e.code ?? "")) {
setManual(true);
setMarket(link.trim());
}
} finally {
setBusy(false);
}
};
const addPicked = async () => {
if (!info) return;
setBusy(true);
let n = 0;
try {
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
await api.artifacts.addPlugin({
key: p.key,
market: info.market,
title: p.name,
description: p.description,
});
n++;
}
toast.success(t("plugin.addedMany", { count: n }));
onAdded();
onClose();
} catch (err) {
toast.error(err);
if (n) onAdded();
} finally {
setBusy(false);
}
};
const toggle = (k: string) =>
setPicked((s) => {
const next = new Set(s);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
return (
<div className="space-y-3">
<form
className="space-y-1"
onSubmit={(e) => {
e.preventDefault();
if (link.trim()) void read();
}}
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<input
className={field}
value={link}
autoFocus
placeholder="https://git.example.com/org/claude-skills.git"
onChange={(e) => setLink(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !link.trim()}
>
{busy && !info ? t("plugin.reading") : t("plugin.read")}
</button>
</div>
</label>
<p className={hint}>{t("plugin.linkHint")}</p>
</form>
{error && (
<p
role="alert"
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
>
{error}
</p>
)}
{info && (
<div className="space-y-2" data-testid="market-plugins">
<div>
<p className="text-sm font-medium text-fg-primary">
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
{info.name}
</p>
{info.description && <p className={hint}>{info.description}</p>}
</div>
{info.plugins.length === 0 ? (
<p className={hint}>{t("plugin.none")}</p>
) : (
<>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={picked.size === info.plugins.length}
onChange={(e) =>
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
}
/>
{t("plugin.all")}
</label>
<ul className="max-h-64 space-y-1 overflow-auto">
{info.plugins.map((p) => (
<li key={p.key}>
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
className="mt-0.5"
checked={picked.has(p.key)}
onChange={() => toggle(p.key)}
/>
<span className="min-w-0 flex-1">
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
{p.version && (
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
)}
{have.has(p.key) && (
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{t("plugin.have")}
</span>
)}
{p.description && (
<span className="block text-[11px] text-fg-muted">{p.description}</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="flex justify-end">
<button
type="button"
className="btn-primary text-sm"
disabled={busy || picked.size === 0}
onClick={() => void addPicked()}
>
{t("plugin.addPicked", { count: picked.size })}
</button>
</div>
</>
)}
</div>
)}
<div className="border-t border-border-light pt-2">
<button
type="button"
className="text-xs text-accent hover:underline"
aria-expanded={manual}
onClick={() => setManual((v) => !v)}
>
{t("plugin.manual")}
</button>
{manual && (
<form
className="mt-2 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
try {
await api.artifacts.addPlugin({
key: key.trim(),
market: market.trim(),
title,
description,
});
toast.success(t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo · https://… · git@host:org/repo.git"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
</div>
</div>
);
}
// ── File upload ───────────────────────────────────────────────────────────
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
const { t } = useTranslation("artifacts");
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const tooBig = !!file && file.size > 25 * 1024 * 1024;
return (
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (!file) return;
setBusy(true);
try {
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
toast.success(t("added"));
setFile(null);
setTitle("");
onAdded();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<label className={label}>
{t("file.fromComputer")}
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
<div className="flex gap-2">
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !file || tooBig}
>
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
</button>
</div>
</form>
);
}
@@ -5,7 +5,19 @@
* the message stream via MessageList. Combines a WebSocket subscription, a
* visibility-gated polling fallback, and a manual refresh button so the view
* stays caught up even when hooks miss frames or the user is mid-text-only
* turn (no PreToolUse fires until Stop).
* turn (no PreToolUse fires until Stop). The view sticks to its end while
* the reader is there: anything that makes it taller — a new line, the live
* step, an image loading, the pane shown again after being hidden by CSS
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
* chat passes in whether the CLI waits on the user (`waiting`, with
* `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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -53,12 +65,15 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef } from "react";
import { memo, useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import { MessageList, promptTokens } from "./MessageList";
import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -68,18 +83,73 @@ 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;
// A hook burst (Pre + Post per tool call, several sessions) refetches the tail
// at most this often: the first event still fetches at once, the last is never
// lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500;
interface ConversationViewProps {
sessionId: string;
initialTranscriptId?: string | null;
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
/** 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. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
/** The CLI is asking the user something (a permission prompt). */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
stopped?: number;
/** The newest reply's prompt size (input + cache) and model: how full the
* context is. */
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;
}
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
function ConversationViewPane({
sessionId,
initialTranscriptId,
fill = false,
onOpenFile,
live = false,
follow = 0,
waiting = false,
onOpenTerminal,
stopped = 0,
onContext,
onNotStarted,
ended = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -90,11 +160,14 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
// Scrolled up from the end: a chat (`fill`) offers the way back down.
const [awayFromEnd, setAwayFromEnd] = useState(false);
// Track JSONL line numbers for incremental requests and history loading
const lastLineRef = useRef(0);
const firstLineRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const fetchingRef = useRef(false);
// When a fetch is in flight and a new trigger arrives (WS event, poll,
@@ -104,9 +177,12 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Bumped whenever the session or the selected transcript changes: a tail
// fetch started for the previous transcript must not append to the new one.
const genRef = useRef(0);
// A pane of another workspace or tab stays mounted but hidden: it neither
// polls nor follows events, and catches up once when it is shown again.
const onShowRef = useRef<() => void>(() => {});
const onScreen = useOnScreen(scrollContainerRef, () => onShowRef.current());
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
const [refreshing, setRefreshing] = useState(false);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
@@ -122,14 +198,18 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}
}
loadTranscripts();
const interval = window.setInterval(() => {
if (document.visibilityState === "visible") 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(() => {
@@ -155,6 +235,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
limit: 50,
});
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setTotal(result.total);
setHasMore(result.has_more);
@@ -162,7 +243,11 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
firstLineRef.current = result.first_line;
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
// A live pane's new session has no row until its first hook: nothing
// to show yet, and the poll below picks it up once it exists.
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
setNotStarted(notYet);
setMessages([]);
setTotal(0);
} finally {
@@ -202,6 +287,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
limit: 50,
});
if (gen !== genRef.current) return; // another transcript is shown now
setNotStarted(false);
if (result.messages.length === 0) return;
lastLineRef.current = result.last_line;
@@ -237,22 +323,33 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, selectedTranscript]);
// WebSocket subscription: refetch on every new_event for this session.
// WebSocket subscription: refetch on new_event for this session, throttled.
// Hook coverage isn't complete (a user-typed message fires no hook), so we
// also poll below to catch what WS misses.
const fetchSoon = useMemo(
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab or hidden pane: catching up once on show is enough.
if (!isShown(onScreen)) return;
if (isRemoteDataRefreshMessage(msg)) {
fetchNewMessages();
fetchSoon();
return;
}
if (msg.type !== "new_event") return;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchNewMessages();
fetchSoon();
});
return unsubscribe;
}, [sessionId, fetchNewMessages]);
}, [sessionId, fetchSoon, onScreen]);
onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages();
};
// Resync on WebSocket reconnect: events that landed during a transient
// disconnect are gone from the bus, but the JSONL still has them, so a
@@ -272,9 +369,12 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
let interval: number | null = null;
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (document.visibilityState === "visible") fetchNewMessages();
}, POLL_INTERVAL_MS);
interval = window.setInterval(
() => {
if (isShown(onScreen)) fetchNewMessages();
},
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
);
}
function stop() {
if (interval !== null) {
@@ -299,18 +399,11 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages]);
}, [fetchNewMessages, onScreen, ended]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
const refresh = useCallback(async () => {
setRefreshing(true);
try {
await fetchNewMessages();
} finally {
setRefreshing(false);
}
}, [fetchNewMessages]);
const [refreshing, refresh] = useSpin(fetchNewMessages);
// Scroll-up to load history
const loadHistory = useCallback(async () => {
@@ -375,6 +468,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Detect if at bottom
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
isAtBottomRef.current = atBottom;
setAwayFromEnd(!atBottom);
// Hide "new messages" indicator when scrolled to bottom
if (atBottom) {
@@ -387,6 +481,30 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}
}, [hasMore, loadingHistory, loadHistory]);
// Stick to the end while the reader is there. Scroll events say whether
// they are; a size change — of the content, or of the box itself when a
// hidden pane is shown — never fires one, so it is watched here.
useEffect(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
});
observer.observe(container);
observer.observe(content);
return () => observer.disconnect();
}, []);
// The reader sent a message: show it, and what follows, as it lands.
useEffect(() => {
if (!follow) return;
isAtBottomRef.current = true;
setAwayFromEnd(false);
setShowNewMsg(false);
scrollToBottom();
}, [follow, scrollToBottom]);
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
@@ -395,10 +513,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
{!loading && (
{!loading && !fill && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && (
<div className="relative">
@@ -445,42 +563,92 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
{/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">
↑ {t("conversation.scrollForOlder")}
</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} />
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.notStartedBody")}
</p>
{onOpenTerminal && (
<button
type="button"
onClick={onOpenTerminal}
className="btn-secondary mt-3 text-xs"
>
{t("conversation.openTerminal")}
</button>
)}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList
messages={messages}
loading={false}
onOpenFile={onOpenFile}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
/>
)}
</div>
</div>
{/* Claude Desktop's round "back to the end" button */}
{fill && awayFromEnd && (
<button
type="button"
data-testid="conversation-to-end"
onClick={() => {
scrollToBottom();
setShowNewMsg(false);
}}
aria-label={t("conversation.newMessages")}
title={t("conversation.newMessages")}
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
>
<ArrowDown className="h-4 w-4" />
{showNewMsg && (
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
)}
</button>
)}
{/* New messages indicator */}
{showNewMsg && (
{showNewMsg && !fill && (
<button
onClick={() => {
scrollToBottom();
@@ -495,3 +663,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
</div>
);
}
/** Memoized: a chat's prompt box lives in its parent (PaneChat), and every
* keystroke there re-rendered the whole conversation. */
export const ConversationView = memo(ConversationViewPane);
@@ -4,7 +4,14 @@
* 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.
* and auto-linked URLs. A file path — absolute, 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
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
* of how serious it is.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
@@ -77,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
const QUOTE_RE = /^\s*>\s?(.*)$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
/** A GFM table starts at a line holding a pipe that is followed by a divider. */
function isTableStart(lines: string[], i: number): boolean {
return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!);
}
function splitTableRow(line: string): string[] {
// Trim leading/trailing pipes, then split, respecting escaped pipes.
@@ -164,7 +176,7 @@ function parseBlocks(src: string): Block[] {
}
// Table: header line followed by an alignment divider
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
if (isTableStart(lines, i)) {
const header = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1]!);
i += 2;
@@ -220,7 +232,8 @@ function parseBlocks(src: string): Block[] {
continue;
}
// Paragraph: collect until a blank line or the start of another block
// Paragraph: collect until a blank line or the start of another block —
// a table included, which (as in GFM) may follow prose with no blank line.
const paraLines: string[] = [line];
i++;
while (i < lines.length) {
@@ -232,7 +245,8 @@ function parseBlocks(src: string): Block[] {
HR_RE.test(nl) ||
UL_RE.test(nl) ||
OL_RE.test(nl) ||
QUOTE_RE.test(nl)
QUOTE_RE.test(nl) ||
isTableStart(lines, i)
) {
break;
}
@@ -244,6 +258,113 @@ function parseBlocks(src: string): Block[] {
return blocks;
}
/** Opens a file a message names; provided by a view that can show one. */
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
const LINK_CLASS =
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
// A relative path needs a folder AND an extension starting with a letter
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
// (`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 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;
/** `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.
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
// A link starts a word: "and/or" or "a/b/c" stays text.
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
// "2fd0c903" stay text.
const NUM_RE =
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
const numStart = (text: string, i: number) =>
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
const LABEL_RE =
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
const LABEL_CLASS = (word: string) =>
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
? "text-status-danger"
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
? "text-status-warning"
: "text-accent";
function UrlLink({ url }: { url: string }) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{url}
</a>
);
}
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
function decodeHref(href: string): string {
let p = href
.replace(/^file:\/\//i, "")
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
.replace(/#.*$/, "");
try {
p = decodeURIComponent(p);
} catch {
/* a stray % stays as written */
}
return p;
}
/** A path as a button that opens it, or as it was when nothing can. */
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
const open = React.useContext(OpenFileContext);
if (!open) return <>{children}</>;
return (
<button
type="button"
title={path}
onClick={() => open(stripLine(path))}
className={`${LINK_CLASS} text-left`}
>
{children}
</button>
);
}
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
export function linkify(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let buf = "";
for (let i = 0; i < text.length;) {
const rest = text.slice(i);
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
const path = !url && atWordStart(text, i) && matchPath(rest);
const hit = url || path;
if (!hit) {
buf += text[i++];
continue;
}
if (buf) out.push(buf);
buf = "";
out.push(
url ? (
<UrlLink key={i} url={url} />
) : (
<PathLink key={i} path={hit}>
{hit}
</PathLink>
)
);
i += hit.length;
}
if (buf) out.push(buf);
return out;
}
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
@@ -267,11 +388,12 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Inline code: `...`
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
push(
const code = (
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
{codeM[1]}
</code>
);
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
i += codeM[0].length;
continue;
}
@@ -280,7 +402,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) {
push(
<strong className="font-semibold text-fg-primary">
<strong className="font-bold text-accent">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong>
);
@@ -315,9 +437,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Markdown link
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
if (linkM) {
const href = linkM[2]!;
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
if (
/^file:\/\//i.test(href) ||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
) {
push(
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
);
i += linkM[0].length;
continue;
}
push(
<a
href={linkM[2]!}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
@@ -330,22 +464,39 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
}
// Auto-link
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
const urlM = rest.match(URL_RE);
if (urlM) {
push(
<a
href={urlM[0]}
target="_blank"
rel="noopener noreferrer"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
>
{urlM[0]}
</a>
);
push(<UrlLink url={urlM[0]} />);
i += urlM[0].length;
continue;
}
// An absolute path at a word start
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
if (pathM) {
push(<PathLink path={pathM}>{pathM}</PathLink>);
i += pathM.length;
continue;
}
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
if (labelM) {
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
i += labelM[0].length;
continue;
}
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
if (numM) {
push(
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
{numM[0]}
</span>
);
i += numM[0].length;
continue;
}
buf += text[i]!;
i++;
}
@@ -381,6 +532,8 @@ interface MarkdownContentProps {
text: string;
/** Tighter spacing for nested contexts (list items, quotes). */
dense?: boolean;
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
prose?: boolean;
}
const HEADING_STYLES = [
@@ -392,12 +545,16 @@ const HEADING_STYLES = [
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
];
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
const blocks = parseBlocks(text);
const gap = dense ? "space-y-1.5" : "space-y-2.5";
// A paragraph or list item takes the root's size and colour in prose.
const bodyText = prose ? "" : "text-sm text-fg-secondary";
return (
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
<div
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
>
{blocks.map((b, idx) => {
switch (b.kind) {
case "code":
@@ -420,7 +577,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
@@ -433,7 +590,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
@@ -503,7 +660,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
case "para":
return (
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
{renderInline(b.text, `p${idx}`)}
</p>
);
File diff suppressed because it is too large Load Diff
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
}
/** Build a one-line summary of the tool call to show in the collapsed header. */
function buildSummary(toolUse: TranscriptContent): string | null {
export function buildSummary(toolUse: TranscriptContent): string | null {
const input = toolUse.input;
if (!input || typeof input !== "object" || "_truncated" in input) return null;
const obj = input as Record<string, unknown>;
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
/** Render the input pane with tool-aware formatting. */
function renderInput(toolUse: TranscriptContent) {
export function renderInput(toolUse: TranscriptContent) {
const input = toolUse.input;
if (!input) return null;
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
}
/** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) {
export function renderResult(toolResult: TranscriptContent, toolName: string) {
const text = toolResult.output ?? "";
if (text.length === 0)
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
@@ -0,0 +1,93 @@
/**
* @file ConversationView.follow.test.tsx
* @description A chat sticks to its end: when it grows or is shown again
* after being hidden (a size change, which fires no scroll event) it scrolls
* to the newest while the reader is at the end, leaves a reader who scrolled
* up alone, and a send (`follow`) brings it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let resize: (() => void) | null = null;
const original = globalThis.ResizeObserver;
beforeEach(() => {
resize = null;
// @ts-expect-error test stub
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
resize = cb;
}
observe() {}
disconnect() {}
};
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
vi.restoreAllMocks();
});
/** The scroll box, with the layout jsdom does not compute. */
function box(height: number) {
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
return el;
}
describe("ConversationView sticks to its end", () => {
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
const view = render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const el = box(1000);
// Shown again after display:none, or taller: only a resize says so.
act(() => resize?.());
expect(el.scrollTop).toBe(1000);
// The reader scrolls up: growing no longer pulls them down.
el.scrollTop = 100;
fireEvent.scroll(el);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
act(() => resize?.());
expect(el.scrollTop).toBe(100);
// They send a message: back to the end, and it sticks again.
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
expect(el.scrollTop).toBe(1400);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
act(() => resize?.());
expect(el.scrollTop).toBe(1600);
});
});
@@ -0,0 +1,96 @@
/**
* @file ConversationView.hidden.test.tsx
* @description A chat kept mounted in a hidden pane (another workspace or
* tab, `display:none`) does not follow the live stream: a new_event for its
* session fetches nothing while its box is off screen, and showing it again
* catches the tail up once. On screen, a burst of events is throttled to one
* fetch at once plus one after the burst.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
import { api } from "../../../lib/api";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let intersect: ((visible: boolean) => void) | null = null;
const original = globalThis.IntersectionObserver;
beforeEach(() => {
intersect = null;
// @ts-expect-error test stub
globalThis.IntersectionObserver = class {
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
intersect = (visible) => cb([{ isIntersecting: visible }]);
}
observe() {}
disconnect() {}
};
});
afterEach(() => {
globalThis.IntersectionObserver = original;
vi.useRealTimers();
vi.clearAllMocks();
});
const event = () =>
act(() => eventBus.publish({ type: "new_event", data: { session_id: "s1" } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView in a hidden pane", () => {
it("fetches nothing while hidden and catches up once when shown", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
act(() => intersect?.(false));
event();
event();
expect(calls()).toBe(before);
act(() => intersect?.(true));
await act(async () => {});
expect(calls()).toBe(before + 1);
});
it("throttles a burst of events on screen", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
event();
event();
event();
// The first fetches at once; the rest wait for the throttle window.
expect(calls()).toBe(before + 1);
await act(async () => {
await vi.advanceTimersByTimeAsync(1600);
});
expect(calls()).toBe(before + 2);
});
});
@@ -0,0 +1,58 @@
/**
* @file ConversationView.notYet.test.tsx
* @description A live pane's new session has no row until its first hook:
* its 404 shows no "Session not found" (a replay still shows it) but says the
* session has not started — the CLI may be asking in the terminal, e.g. to
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
const transcript = vi.hoisted(() =>
vi.fn(async () => {
throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 });
})
);
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } },
};
});
describe("ConversationView before a session's first hook", () => {
it("shows no error for a live pane", async () => {
render(<ConversationView sessionId="new" fill live />);
await waitFor(() => expect(transcript).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Session not found")).toBeNull();
});
it("still reports it when not live", async () => {
render(<ConversationView sessionId="gone" fill />);
expect(await screen.findByText("Session not found")).toBeTruthy();
});
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
const onOpenTerminal = vi.fn();
const onNotStarted = vi.fn();
render(
<ConversationView
sessionId="new"
fill
live
onOpenTerminal={onOpenTerminal}
onNotStarted={onNotStarted}
/>
);
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
expect(screen.queryByText("No conversation records found.")).toBeNull();
fireEvent.click(screen.getByText("Open terminal"));
expect(onOpenTerminal).toHaveBeenCalled();
expect(onNotStarted).toHaveBeenLastCalledWith(true);
});
});
@@ -7,8 +7,52 @@
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MarkdownContent } from "../MarkdownContent";
import { vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
import { resolveIn } from "../../../lib/resolvePath";
function withOpen(text: string) {
const open = vi.fn();
render(
<OpenFileContext.Provider value={open}>
<MarkdownContent text={text} />
</OpenFileContext.Provider>
);
return open;
}
describe("file links", () => {
it("opens a relative path with its line suffix stripped", () => {
const open = withOpen("See server/lib/x.js:42 for it.");
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
expect(open).toHaveBeenCalledWith("server/lib/x.js");
});
it("opens an absolute path with a line suffix and one in inline code", () => {
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.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" }));
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
});
it("leaves prose with slashes as text", () => {
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
expect(screen.queryAllByRole("button")).toHaveLength(0);
});
it("resolves a relative path against the pane's folder", () => {
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
});
});
describe("<MarkdownContent />", () => {
it("renders fenced code blocks with the language label", () => {
@@ -66,10 +110,61 @@ describe("<MarkdownContent />", () => {
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
});
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
const { container } = render(
<MarkdownContent
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
});
it("highlights an ISO date-time as one number", () => {
const { container } = render(
<MarkdownContent
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual([
"2026-10-12T08:00",
"2026-10-12T08:00:00Z",
"2026-10-12T08:00:00.123+07:00",
]);
});
it("does not highlight numbers inside inline code", () => {
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
expect(container.querySelector("span.tabular-nums")).toBeNull();
});
it("colours a flag label by how serious it is", () => {
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
expect(screen.queryByText("Note")).toBeNull();
});
it("handles empty input safely", () => {
const { container } = render(<MarkdownContent text="" />);
// Wrapper exists but no block elements
expect(container.firstChild).not.toBeNull();
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
});
it("renders a table that follows prose with no blank line (issue #83)", () => {
const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |';
const { container } = render(<MarkdownContent text={text} />);
expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:");
expect(container.querySelectorAll("th").length).toBe(2);
expect(container.querySelectorAll("tbody tr").length).toBe(1);
expect(container.textContent).not.toContain("|---|");
});
it("accepts a GFM divider with single dashes", () => {
const { container } = render(<MarkdownContent text={"| a | b |\n|-|:-:|\n| 1 | 2 |"} />);
expect(container.querySelectorAll("th").length).toBe(2);
});
});
@@ -0,0 +1,104 @@
/**
* @file MessageList.ask.test.tsx
* @description An AskUserQuestion call is not folded into the collapsed work
* line: the chat shows it as its own card — each question with the answer the
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
* CLI still waits.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-13T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const ask = (id: string, questions: string[]): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
{
type: "tool_use",
id,
name: "AskUserQuestion",
input: {
questions: questions.map((question) => ({
question,
header: "Pick",
options: [{ label: "Yes" }, { label: "No" }],
})),
},
},
],
}) as TranscriptMessage;
const result = (id: string, extra: Record<string, unknown>) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "x", ...extra }],
}) as TranscriptMessage;
describe("an AskUserQuestion card", () => {
it("shows each question with the user's answer, outside the work line", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Push?"]),
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("only after review")).toBeTruthy();
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
});
it("drops the (Recommended) marker from a picked answer", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Tools?"]),
result("q1", {
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
}),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("Lint, Test")).toBeTruthy();
expect(within(card).queryByText(/Recommended/)).toBeNull();
});
it("says declined when refused, and shows no answer while still asked", () => {
const { rerender } = render(
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).queryByText(/declined/i)).toBeNull();
rerender(
<MessageList
loading={false}
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
/>
);
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
});
});
@@ -0,0 +1,103 @@
/**
* @file MessageList.edits.test.tsx
* @description Each assistant turn that changed files ends with Claude
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, a row click handed
* to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-09T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id, name, input: { file_path } }],
}) as TranscriptMessage;
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
}) as TranscriptMessage;
describe("the Edited N files card", () => {
it("sums each file's lines over the turn and opens a file on click", () => {
const onOpenFile = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
call("4", "Edit", "/w/c.ts"),
result("4", undefined, true),
user("thanks"),
call("5", "Read", "/w/a.ts"),
result("5"),
]}
/>
);
const cards = screen.getAllByTestId("edited-files");
expect(cards).toHaveLength(1); // the second turn only read
const card = cards[0]!;
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
});
it("drags a row as its @path attachment, and the header as every file", () => {
render(
<MessageList
loading={false}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 1, removed: 0 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 2, removed: 0 }),
]}
/>
);
const card = screen.getByTestId("edited-files");
const drag = (el: Element) => {
const data: Record<string, string> = {};
fireEvent.dragStart(el, {
dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" },
});
return data;
};
const one = drag(within(card).getByTitle("/w/b.md"));
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
{ path: "@/w/b.md", name: "b.md" },
]);
expect(one["text/plain"]).toBe("/w/b.md");
const all = drag(within(card).getByText("Edited 2 files"));
expect(
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.md"]);
});
});
@@ -0,0 +1,133 @@
/**
* @file MessageList.images.test.tsx
* @description An image the prompt box attached reaches the transcript as its
* saved drop path; the user's turn shows the image (served by
* `GET /api/run/drop/:name`), opens it full size on the page; any other
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
* URLs and absolute paths in a turn are links.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, splitDropImages } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const SHOT = "366d0a87d12176a2-image.png";
describe("dropped images in a user turn", () => {
it("splitDropImages takes drop-dir image paths out of the text", () => {
expect(
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
]);
// The drop dir since the data-dir move.
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
text: "fix this",
images: [SHOT],
files: [],
});
// Any other dropped file comes out by its full path.
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
// Not a drop: left as typed.
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
}
});
it("shows a dropped file as a chip that opens its preview", () => {
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `summarise ${pdf}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).not.toHaveTextContent("0123456789abcdef");
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
expect(opened).toEqual([pdf]);
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("look at this");
expect(bubble).not.toHaveTextContent("agenthub-drops");
const img = screen.getByRole("img", { name: SHOT });
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
fireEvent.click(img);
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
});
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
},
{
type: "user",
sender: "system",
timestamp: "2026-10-09T09:43:11.000Z",
content: [
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
expect(screen.queryByText("System")).toBeNull();
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
expect(bubble).not.toHaveTextContent("[Image #1]");
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
`/api/run/drop/${SHOT}`
);
});
it("makes URLs and absolute paths clickable", () => {
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [
{
type: "text",
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
},
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
expect(link.getAttribute("target")).toBe("_blank");
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
});
});
@@ -1,13 +1,16 @@
/**
* @file MessageList.sender.test.tsx
* @description Verifies the transcript renders each message under its TRUE
* sender label — User / Assistant / Main agent / System — instead of labeling
* every `type:"user"` line "User" (reported transcript mis-attribution).
* @description Verifies the transcript renders each message by its TRUE
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MessageList } from "../MessageList";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -20,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
}
describe("MessageList — sender attribution", () => {
it("shows a typed /command and @path as chips in the user's bubble", () => {
render(
<MessageList
messages={[
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
]}
loading={false}
/>
);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("/review @src/a.ts now");
expect(within(bubble).getByText("/review").className).toContain("text-accent");
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
});
it("labels each row by its sender, not blanket 'User'", () => {
const messages: TranscriptMessage[] = [
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
@@ -36,8 +54,8 @@ describe("MessageList — sender attribution", () => {
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
expect(screen.getByText("System")).toBeInTheDocument();
expect(screen.getByText("Main agent")).toBeInTheDocument();
});
@@ -48,7 +66,281 @@ describe("MessageList — sender attribution", () => {
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
});
it("folds consecutive tool calls — one per transcript line — into one line", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
render(<MessageList messages={messages} loading={false} />);
const group = screen.getAllByTestId("chat-tools");
expect(group).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
expect(group[0]).toHaveTextContent("echo b");
});
it("names a mixed run by what it did, like Claude Desktop", () => {
const use = (name: string) => ({ type: "tool_use" as const, name });
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
"Ran 2 commands, edited a file, read a file"
);
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
// Desktop's fixed order, whatever ran first: other tools always last.
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
"Ran 1 command, read a file, used a tool"
);
});
it("reads a thinking block's text as narration, between two groups", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const say = (text: string) =>
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
render(
<MessageList
loading={false}
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
/>
);
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
expect(screen.getByText("Found an issue.")).toBeTruthy();
});
it("shows the whole prompt as ↓, not just its uncached tail", () => {
render(
<MessageList
loading={false}
messages={[
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: 2,
output_tokens: 40,
cache_read_input_tokens: 120000,
cache_creation_input_tokens: 3000,
},
}),
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
});
it("ends a live chat with the step still running", () => {
const messages = [
msg({
type: "user",
sender: "user",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [{ type: "text", text: "test it" }],
}),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 5_000).toISOString(),
usage: { input_tokens: 2, output_tokens: 3500 },
content: [
{
type: "tool_use",
id: "t1",
name: "Bash",
input: { command: "npm test", description: "Run the full client suite" },
},
],
}),
];
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
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\)/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
loading={false}
live
messages={[
...messages,
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
]}
/>
);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
// Issue #74: Esc before the model wrote anything takes the prompt back into
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
const prompt = msg({
sender: "user",
timestamp: new Date(Date.now() - 14_000).toISOString(),
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
});
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
// The next prompt is a new turn: its Thinking… shows.
const next = msg({
sender: "user",
timestamp: new Date().toISOString(),
content: [{ type: "text", text: "again" }],
});
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
});
it("counts a turn's output once per API call, since its prompt", () => {
const at = "2026-06-26T08:19:00.000Z";
const line = (cache: number, out: number) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "x" }],
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
});
const messages = [
msg({ sender: "user" }),
line(10, 999), // an earlier turn
msg({ sender: "user", timestamp: at }),
line(100, 40),
line(100, 40), // the same call's second block
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
line(200, 25),
];
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
});
it("keeps every parallel call of the turn running until its own result", () => {
const at = "2026-06-26T08:19:00.000Z";
const now = Date.parse("2026-06-26T08:20:00.000Z");
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
});
const done = (id: string) =>
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
// Claude Code writes each parallel call on its own line; one result is in.
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
const results = new Map([["b", m[4]!.content[0]!]]);
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
// A call left unpaired in an EARLIER turn is not this one's.
const next = [
...m,
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
];
expect(workingOn(next, results, now)?.blocks).toEqual([]);
});
it("says when the CLI waits on the user, and offers the terminal", () => {
let opened = 0;
render(
<MessageList
loading={false}
live
waiting
onOpenTerminal={() => opened++}
messages={[
msg({ sender: "user", timestamp: new Date().toISOString() }),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date().toISOString(),
content: [
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
],
}),
]}
/>
);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
fireEvent.click(screen.getByTestId("chat-open-terminal"));
expect(opened).toBe(1);
});
it("closes an ended turn with its time and output", () => {
const reply = (out: number, stop_reason: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: "2026-06-26T08:16:08.000Z",
stop_reason,
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
content: [{ type: "text", text: "done" }],
});
render(
<MessageList
loading={false}
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
/>
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
});
it("knows what a live turn is doing until it ends", () => {
const now = Date.parse("2026-06-26T08:20:00.000Z");
const at = "2026-06-26T08:19:00.000Z";
const prompt = msg({ type: "user", sender: "user", timestamp: at });
const call = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
});
const result = msg({
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id: "t1", output: "ok" }],
});
const reply = (stop_reason?: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason,
content: [{ type: "text", text: "ok" }],
});
const results = (m: ReturnType<typeof msg>[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
const thinking = { blocks: [], since: null, last: at };
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
const stopped = msg({
type: "user",
sender: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
});
+220 -86
View File
@@ -1,15 +1,21 @@
/**
* @file AddLaneModal.tsx
* @description The "+ Add lane" flow, in one of two modes chosen with a
* segmented toggle: "Repo" adopts an existing directory as-is via
* @description The "+ Add lane" flow, in one of three modes chosen with a
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
* main repo you want stage detection on); "Worktree" provisions a
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
* manually-typed branch name. The user also picks which CLI drives the lane
* (Claude Code or Codex) — this choice is immutable after creation. Either
* 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
* 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
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
* which a click into the field opens too.
* The worktree lane returned is `status: "provisioning"`:
* the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the
@@ -19,7 +25,7 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, X } from "lucide-react";
import { X } from "lucide-react";
import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
@@ -27,7 +33,7 @@ import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { readDefaultTool } from "../../lib/sessionDefaults";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -45,6 +51,17 @@ async function waitForProvisioned(
}
}
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
export function cloneDirName(url: string): string {
return (
url
.trim()
.replace(/[/:]+$/, "")
.split(/[/:]/)
.pop() || ""
).replace(/\.git$/i, "");
}
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
function Seg({
active,
@@ -94,7 +111,18 @@ export function AddLaneModal({
cwdSuggestions: CwdSuggestion[];
}) {
const { t } = useTranslation(["lanes"]);
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
// server asked for one — a login for the URL's https host.
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [login, setLogin] = useState<{
host: string;
username: string;
token: string;
remember: boolean;
} | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [sourceRepo, setSourceRepo] = useState("");
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
@@ -107,20 +135,18 @@ export function AddLaneModal({
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState("");
const [branchesError, setBranchesError] = useState<string | null>(null);
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
// No picker: a workspace's tool is only its DEFAULT — each new session
// picks its own AI on the workspace home. This is the default AI tool
// (Settings → AI Tools), the account's own pick first while it is
// installed and on.
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
const tool = pickedTool ?? defaultTool;
// Installed + enabled, the default first.
const toolChoices = aiTools.tools
.filter((x) => x.installed && x.enabled)
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
const [toolListOpen, setToolListOpen] = useState(false);
const pickTool = (id: string) => {
setPickedTool(id === defaultTool ? null : id);
setToolListOpen(false);
};
const ownTool = readDefaultTool();
const tool =
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
? ownTool
: null) ??
aiTools.defaultId ??
"claude";
const noTools = aiTools.loaded && aiTools.defaultId === null;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -130,7 +156,11 @@ export function AddLaneModal({
} | null>(null);
const reset = () => {
setMode("worktree");
setMode("repo");
setCloneUrl("");
setCloneName("");
setLogin(null);
setSshRefused(false);
setSourceRepo("");
setTitle("");
setBranch("");
@@ -138,7 +168,6 @@ export function AddLaneModal({
setWorktrees([]);
setBranchMode("new");
setBase("");
setPickedTool(null);
setBranchesError(null);
setError(null);
setBusy(false);
@@ -151,6 +180,8 @@ export function AddLaneModal({
if (!open || !prefill) return;
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
if (prefill.branch) {
// A branch only means something to a worktree.
setMode("worktree");
setBranch(prefill.branch);
setBranchMode("existing");
}
@@ -201,6 +232,52 @@ export function AddLaneModal({
setBusy(true);
setError(null);
if (mode === "clone") {
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
try {
const result = await api.lanes.clone({
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onAdded(result.lane);
reset();
onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || url;
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;
}
if (mode === "repo") {
try {
const result = await api.lanes.ensure({
@@ -325,6 +402,8 @@ export function AddLaneModal({
const disabled =
!done &&
(!sourceRepo.trim() ||
(mode === "clone" &&
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
(mode === "worktree" &&
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
@@ -352,6 +431,7 @@ export function AddLaneModal({
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-tour="add-lane"
>
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
@@ -380,73 +460,57 @@ export function AddLaneModal({
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
<Seg
active={mode === "clone"}
label={t("mode.clone")}
title={t("mode.cloneHint")}
onClick={() => setMode("clone")}
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings/agents" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
) : (
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
{mode === "clone" && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
{t("clone.urlLabel")}
</label>
<input
id="add-lane-url"
value={cloneUrl}
onChange={(e) => {
setCloneUrl(e.target.value);
// A login belongs to the host it was asked for.
setLogin(null);
setSshRefused(false);
}}
placeholder="https://github.com/owner/repo.git"
spellCheck={false}
autoComplete="off"
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"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
</div>
)}
{noTools && (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings/agents" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
{mode === "repo"
? t("addLaneRepoLabelAdopt")
: mode === "clone"
? t("clone.parentLabel")
: t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="flex gap-1.5" data-tour="add-lane-folder">
{/* A click into the path only focuses it — the picker opens from
Browse alone (#77). */}
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
@@ -458,10 +522,67 @@ export function AddLaneModal({
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
{mode === "clone"
? t("clone.parentHint", {
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
cloneName.trim() || cloneDirName(cloneUrl) || "…"
}`,
})
: mode === "repo"
? t("addLaneRepoHintAdopt")
: t("addLaneRepoHint")}
</p>
</div>
{mode === "clone" && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
{t("clone.nameLabel")}
</label>
<input
id="add-lane-clone-name"
value={cloneName}
onChange={(e) => setCloneName(e.target.value)}
placeholder={cloneDirName(cloneUrl)}
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"
/>
</div>
)}
{mode === "clone" && login && (
<div
data-testid="add-lane-clone-login"
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="off"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<input
type="password"
aria-label={t("clone.token")}
placeholder={t("clone.token")}
autoComplete="new-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
/>
{t("clone.remember")}
</label>
</div>
)}
{/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */}
@@ -675,6 +796,14 @@ export function AddLaneModal({
{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>
@@ -692,9 +821,14 @@ export function AddLaneModal({
type="button"
onClick={done ? handleCancel : submit}
disabled={busy || disabled}
data-tour="add-lane-create"
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
{done
? t("addLaneSetupClose")
: busy && mode === "clone"
? t("clone.cloning")
: t("add")}
</button>
</div>
</div>
@@ -0,0 +1,188 @@
/**
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
* (`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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type LaneSourcesView } from "../../lib/api";
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
type Login = { host: string; username: string; token: string; remember: boolean };
export function CloneSourcesForm({
laneId,
parent,
onAdded,
}: {
laneId: number;
/** Where the clones land: the workspace folder's parent, shown only. */
parent: string;
onAdded: (view: LaneSourcesView) => void;
}) {
const { t } = useTranslation("lanes");
const [text, setText] = useState("");
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null);
const urls = (s: string) => [
...new Set(
s
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
),
];
const cloneAll = async (list: string[]) => {
if (busy || !list.length) return;
setBusy(true);
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
const show = () => setRows([...next]);
show();
let asked: Login | null = null;
for (const row of next) {
row.state = "busy";
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
row.state = "done";
onAdded(view);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || row.url;
row.state = "failed";
if (e.code === "ECREDENTIALS") {
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
asked ??= {
host,
username: login?.username ?? "",
token: "",
remember: login?.remember ?? true,
};
} else if (e.code === "ESSHAUTH") {
row.note = t("clone.sshRefused", { host });
} else {
row.note = e.message || String(err);
}
}
show();
}
if (asked) setLogin(asked);
// What is left to do stays in the box; what was added leaves it.
setText(
next
.filter((r) => r.state !== "done")
.map((r) => r.url)
.join("\n")
);
setBusy(false);
};
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
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>
<textarea
data-testid="workspace-clone-input"
aria-label={t("home.cloneFolders")}
placeholder={t("home.clonePlaceholder")}
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
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>
{login && (
<div className="space-y-1.5 rounded border border-border-light p-2">
<div className="flex gap-2">
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
/>
<input
type="password"
aria-label={t("clone.token")}
placeholder={t("clone.token")}
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
/>
</div>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
/>
{t("clone.remember")}
</label>
</div>
)}
<div className="flex justify-end gap-2">
{failed.length > 0 && !busy && (
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
{t("home.cloneRetry")}
</button>
)}
<button
type="button"
data-testid="workspace-clone-run"
disabled={busy || !urls(text).length}
onClick={() => void cloneAll(urls(text))}
className="btn-ghost text-xs"
>
{busy ? t("clone.cloning") : t("home.cloneRun")}
</button>
</div>
{rows.length > 0 && (
<ul className="space-y-1" data-testid="workspace-clone-rows">
{rows.map((r) => (
<li key={r.url} className="text-[11px]">
<span className="flex items-center gap-1.5">
{r.state === "busy" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
) : r.state === "done" ? (
<Check
className="h-3 w-3 shrink-0 text-status-success"
aria-label={t("home.cloneDone")}
/>
) : r.state === "failed" ? (
<X className="h-3 w-3 shrink-0 text-status-danger" />
) : (
<span className="h-3 w-3 shrink-0" />
)}
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
{r.url}
</span>
</span>
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
</li>
))}
</ul>
)}
</div>
);
}
+364 -29
View File
@@ -3,7 +3,9 @@
* file's original text (HEAD, or a branch's merge base), editable while the
* diff re-colours as you type. `unified` is one editor with the removed lines
* inline above their replacement (`unifiedMergeView`); `split` is the
* original read-only on the left beside the editable file (`MergeView`).
* original read-only on the left beside the editable file, JetBrains-shaped —
* two scrollers kept on matching lines, no padding, a curved band per changed
* block between them (`splitDiff.ts`).
* `collapse` folds unchanged stretches away — the panel's Changes view —
* and every changed block carries a Revert that puts the original back in the
* editor (nothing is written until Save). Green marks a line added whole, red
@@ -11,27 +13,55 @@
* text), its edit in a stronger blue — the same scheme as the read-only diff in
* LaneDiffView. Syntax colours come from the dashboard's own per-line
* tokenizer (`lib/highlight.ts`), like every other code view here.
* Given `onBlock`, each changed block also gets the read-only diff's bar under
* its last line — Undo (asks twice) and the block's commit checkbox
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
* the editor holds unsaved text, because git acts on the file on disk.
* Given `blame`, a gutter left of the line numbers names each line's last
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip floating above its last line, at the column where it ends,
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { EditorState, RangeSetBuilder } from "@codemirror/state";
import { useTranslation } from "react-i18next";
import {
Compartment,
EditorSelection,
EditorState,
RangeSetBuilder,
StateEffect,
StateField,
Text,
} from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
drawSelection,
gutter,
GutterMarker,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type Tooltip,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
import { chunksOf, splitDiff } from "./splitDiff";
import { highlight, tokenClass } from "../../lib/highlight";
import type { LaneBlame } from "../../lib/types";
type Blame = Extract<LaneBlame, { available: true }>;
/**
* True when a letter or digit of a line (`text`, starting at document offset
@@ -60,14 +90,14 @@ const mark = (cls: string) => {
* either side of a split view (`a` is the original). */
function decorate(view: EditorView, lang: string): DecorationSet {
const out = new RangeSetBuilder<Decoration>();
const info = getChunks(view.state);
const info = chunksOf(view.state);
const a = info?.side === "a";
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
const chunks = info?.chunks ?? [];
const doc = view.state.doc;
let last = 0;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
for (let pos = from; pos <= to;) {
const line = doc.lineAt(pos);
pos = line.to + 1;
if (line.number <= last) continue;
@@ -139,6 +169,159 @@ const diffColours = (lang: string) =>
{ decorations: (p) => p.decorations }
);
/** A changed block by line (1-based; a count of 0 is a pure insertion or
* removal): what the panel matches against git's own change blocks. */
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
export type BlockAction = "include" | "undo";
/** One Annotate cell: the line's commit date and author, its subject on hover. */
class BlameMarker extends GutterMarker {
constructor(
readonly text: string,
readonly title: string
) {
super();
}
override eq(other: BlameMarker) {
return other.text === this.text && other.title === this.title;
}
override toDOM() {
const el = document.createElement("span");
el.textContent = this.text;
el.title = this.title;
return el;
}
}
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
* lines are matched by number, so unsaved edits above a line shift its
* annotation until the file is saved and re-annotated. */
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
return gutter({
class: "cm-ah-blame",
lineMarker(view, line) {
const sha = shaAt(view.state, line.from);
if (sha === undefined) return null;
const c = sha ? blame.commits[sha] : undefined;
return c
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
: new BlameMarker(notCommitted, notCommitted);
},
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
domEventHandlers: {
mousedown(view, line) {
const sha = shaAt(view.state, line.from);
if (sha) onClick(sha);
return !!sha;
},
},
});
}
/** A chunk side's first line and line count. `to` is the start of the line
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
const lineSpan = (doc: Text, from: number, to: number): [number, number] => {
const first = doc.lineAt(Math.min(from, doc.length)).number;
return [first, from >= to ? 0 : doc.lineAt(Math.min(to - 1, doc.length)).number - first + 1];
};
class BlockBar extends WidgetType {
constructor(
readonly block: EditorBlock,
readonly build: (b: EditorBlock) => HTMLElement
) {
super();
}
eq(o: BlockBar) {
const a = this.block;
const b = o.block;
return (
a.oldFrom === b.oldFrom &&
a.oldCount === b.oldCount &&
a.newFrom === b.newFrom &&
a.newCount === b.newCount
);
}
toDOM() {
return this.build(this.block);
}
}
const barsOn = StateEffect.define<boolean>();
/** One bar per chunk, under its last line (above a pure removal), rebuilt
* whenever the chunks are — and dropped while `barsOn` is false. */
function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
const decos = (doc: Text, chunks: readonly Chunk[]) => {
const out = new RangeSetBuilder<Decoration>();
for (const c of chunks) {
const [oldFrom, oldCount] = lineSpan(orig, c.fromA, c.toA);
const [newFrom, newCount] = lineSpan(doc, c.fromB, c.toB);
const pos = newCount ? doc.line(newFrom + newCount - 1).to : Math.min(c.fromB, doc.length);
out.add(
pos,
pos,
Decoration.widget({
block: true,
side: newCount ? 1 : -1,
widget: new BlockBar({ oldFrom, oldCount, newFrom, newCount }, build),
})
);
}
return out.finish();
};
return StateField.define<{ on: boolean; deco: DecorationSet }>({
create: () => ({ on: false, deco: Decoration.none }),
update(v, tr) {
let on = v.on;
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
const chunks = chunksOf(tr.state)?.chunks;
if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) {
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
}
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
}
/** Add to Chat floating just above the selection's last line, at the column
* where the selection ends — so a long line never pushes it out of view.
* `build` makes its DOM from the selection's first and last 1-based line. A
* selection ending at a line's start (a whole-line drag) does not take that
* line, and the chip then sits at the start of the last line it does take. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
const { from, to, empty } = tr.state.selection.main;
if (empty) return null;
const doc = tr.state.doc;
const first = doc.lineAt(from);
const last = doc.lineAt(to);
const wholeLine = to === last.from && last.number > first.number;
const end = wholeLine ? last.number - 1 : last.number;
return {
pos: wholeLine ? doc.line(end).from : to,
above: true,
create: () => {
const dom = build(first.number, end);
dom.classList.add("cm-chat-chip");
return { dom };
},
};
},
provide: (f) => showTooltip.from(f),
});
}
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
const UNDO =
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
const GREEN = "rgb(var(--status-success) / 0.18)";
const RED = "rgb(var(--status-danger) / 0.18)";
const BLUE = "rgb(var(--diff-mod) / 0.18)";
@@ -167,9 +350,19 @@ const theme = EditorView.theme({
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgb(var(--accent) / 0.3)",
// Doubled classes: drawSelection's base theme scopes its light/dark colours
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
// plain `.cm-selectionBackground` never outranks.
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
{
background: "rgb(var(--accent) / 0.3)",
},
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
border: "1px solid rgb(var(--accent) / 0.6)",
backgroundColor: "rgb(var(--surface-1))",
},
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${M} .cm-changedText`]: { background: "none" },
@@ -192,14 +385,21 @@ const theme = EditorView.theme({
background: "rgb(var(--surface-2))",
},
".cm-chunkButtons": { zIndex: "1" },
".cm-ah-blame .cm-gutterElement": {
color: "rgb(var(--fg-muted))",
cursor: "pointer",
fontSize: "10px",
paddingRight: "6px",
whiteSpace: "nowrap",
},
});
/** The Revert button on a changed block; @codemirror/merge wires the click.
* Between split columns there is room for an arrow only (`→`: left onto right). */
function revertButton(label: string, arrow = false) {
/** The Revert button on a changed block (unified); @codemirror/merge wires
* the click. The split layout draws its own » in the gutter. */
function revertButton(label: string) {
const b = document.createElement("button");
b.type = "button";
b.textContent = arrow ? "→" : label;
b.textContent = label;
b.title = label;
b.setAttribute("aria-label", label);
b.className =
@@ -217,6 +417,12 @@ export default function DiffEditor({
revertLabel,
onChange,
onSave,
onBlock,
blockIncluded,
blame,
onBlameClick,
navRef,
onAddToChat,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
@@ -231,15 +437,85 @@ export default function DiffEditor({
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
onBlock?: (action: BlockAction, block: EditorBlock) => void;
/** Whether a block is ticked for the commit (its checkbox's state). */
blockIncluded?: (block: EditorBlock) => boolean;
/** Annotate: each line's last commit; absent = no gutter. */
blame?: Blame;
onBlameClick?: (sha: string) => void;
/** Set to "go to the next (1) / previous (-1) changed block". */
navRef?: { current: ((dir: 1 | -1) => void) | null };
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
onAddToChat?: (start: number, end: number) => void;
}) {
const { t } = useTranslation("lanes");
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave });
cb.current = { onChange, onSave };
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
const blameSlot = useRef(new Compartment()).current;
/** The diff view's review bar, as DOM: pinned to the visible right edge,
* zero height so it never pushes lines (or a split view's alignment). */
const buildBar = (block: EditorBlock) => {
const tr = cb.current.t;
const wrap = document.createElement("div");
wrap.className = "flex h-0";
const pin = wrap.appendChild(document.createElement("span"));
pin.className = "sticky right-2 ml-auto h-0 w-0 self-start";
const bar = pin.appendChild(document.createElement("span"));
bar.dataset.testid = "lane-editor-block-bar";
bar.className =
"absolute right-0 top-0.5 z-10 flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card";
const chip = (text: string, title: string, cls: string) => {
const b = bar.appendChild(document.createElement("button"));
b.type = "button";
b.textContent = text;
b.title = title;
b.setAttribute("aria-label", title);
b.className = `${CHIP} ${cls}`;
return b;
};
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
// The block's commit checkbox, a real one so it reads like the diff's.
const include = bar.appendChild(document.createElement("input"));
include.type = "checkbox";
include.checked = cb.current.blockIncluded?.(block) ?? true;
include.title = tr("changes.includeBlock");
include.setAttribute("aria-label", tr("changes.includeBlock"));
include.className = "h-3 w-3 cursor-pointer";
// Two clicks, like every undo git cannot give back; disarms after 3 s.
let timer = 0;
const arm = (on: boolean) => {
undo.dataset.armed = on ? "1" : "";
undo.textContent = tr(on ? "changes.undoHunkShortConfirm" : "changes.undoHunkShort");
const title = tr(on ? "changes.undoHunkConfirm" : "changes.undoHunk");
undo.title = title;
undo.setAttribute("aria-label", title);
undo.className = `${CHIP} ${on ? UNDO_ARMED : UNDO}`;
};
undo.onclick = () => {
window.clearTimeout(timer);
if (undo.dataset.armed) {
arm(false);
cb.current.onBlock?.("undo", block);
return;
}
arm(true);
timer = window.setTimeout(() => arm(false), 3000);
};
include.onchange = () => cb.current.onBlock?.("include", block);
return wrap;
};
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme];
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
// would rewrite every line ending.
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
const editing = [
...common,
highlightActiveLineGutter(),
@@ -261,25 +537,41 @@ export default function DiffEditor({
]),
EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
}),
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
blameSlot.of([]),
...(onAddToChat
? [
chatChip((start, end) => {
const b = document.createElement("button");
b.type = "button";
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
return b;
}),
]
: []),
];
let v: EditorView;
let destroy: () => void;
if (layout === "split") {
const mv = new MergeView({
const sd = splitDiff({
parent: host.current!,
a: {
doc: original,
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
},
b: { doc: value, extensions: editing },
revertControls: "a-to-b",
renderRevertControl: () => revertButton(revertLabel, true),
collapseUnchanged: folded,
original,
value,
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
extB: editing,
collapse: folded,
revertLabel,
});
v = mv.b;
destroy = () => mv.destroy();
v = sd.b;
destroy = sd.destroy;
} else {
v = new EditorView({
parent: host.current!,
@@ -306,15 +598,56 @@ export default function DiffEditor({
}
v.focus();
view.current = v;
return destroy;
if (navRef) {
// The B side's chunks, unified or split: the cursor goes to the start
// of the next / previous one and it scrolls into view.
navRef.current = (dir) => {
const ed = view.current;
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
if (!ed || !chunks.length) return;
const at = ed.state.selection.main.head;
const next =
dir === 1
? chunks.find((c) => c.fromB > at)
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
if (!next) return;
ed.dispatch({
selection: EditorSelection.cursor(next.fromB),
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
});
ed.focus();
};
}
return () => {
if (navRef) navRef.current = null;
destroy();
};
// One mount per view: the parent keys this component by file and shape.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Annotate on / off without remounting the view.
useEffect(() => {
view.current?.dispatch({
effects: blameSlot.reconfigure(
blame
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
cb.current.onBlameClick?.(sha)
)
: []
),
});
}, [blame, blameSlot]);
const bars = !!onBlock;
useEffect(() => {
view.current?.dispatch({ effects: barsOn.of(bars) });
}, [bars]);
// A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => {
const v = view.current;
if (v && v.state.doc.toString() !== value) {
if (v && v.state.sliceDoc() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
}
}, [value]);
@@ -324,7 +657,9 @@ export default function DiffEditor({
ref={host}
data-testid="lane-file-editor"
data-layout={layout}
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
data-bars={bars}
// With the git bars up, a block's local Revert would be a second Undo.
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light"
/>
);
}
File diff suppressed because it is too large Load Diff
+653 -69
View File
@@ -1,46 +1,178 @@
/**
* @file A server-backed folder browser for the Add Lane modal's path inputs.
* Browsers refuse to expose an absolute filesystem path from a native folder
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
* up-one-level button and a click-to-descend list, since this tool is
* local-first and the server already trusts arbitrary local paths.
* @file A server-backed folder (or file) browser for the path inputs' Browse
* button, laid out like the OS's own picker. Browsers refuse to expose an
* absolute filesystem path from a native picker, so path selection here is
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
* A click selects, a double-click or Enter opens a folder (picks a file),
* Backspace goes up and typing jumps to a name.
* An admin with a Linux account of their own (S3; the listing carries
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
* browser to the host account's home, where a workspace runs as the shared
* host account. It is never remembered: every open starts at the admin's own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import {
ArrowLeft,
ArrowRight,
ArrowUp,
Clock,
Eye,
File,
FolderGit2,
FolderOpen,
FolderPlus,
Home,
Pencil,
Server,
Star,
} from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { useDialog } from "../../hooks/useDialog";
import { useWorkLanes } from "../../hooks/useLanes";
export function FolderBrowseModal({
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
type Entry = Listing["entries"][number];
const RECENT_KEY = "agenthub.browse.recent";
const HIDDEN_KEY = "agenthub.browse.hidden";
const RECENT_MAX = 8;
function readStored<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function writeStored(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// A private window or blocked storage: the preference just isn't kept.
}
}
/** `[label, path]` for each level of `p`, root first. */
function crumbs(p: string): [string, string][] {
const win = /^[A-Za-z]:\\/.test(p);
const sep = win ? "\\" : "/";
const parts = p.split(sep).filter(Boolean);
const out: [string, string][] = win ? [] : [["/", "/"]];
let acc = "";
for (const part of parts) {
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
out.push([part, acc]);
}
return out;
}
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
type Props = {
open: boolean;
/** `file` lists files too, and picking one is the only way to select. */
kind?: "dir" | "file";
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
onClose: () => void;
};
/**
* Mounted only while open, so a closed picker (one behind every Browse
* button) asks the server for nothing, and each opening starts fresh.
*/
export function FolderBrowseModal(props: Props) {
return props.open ? <BrowseDialog {...props} /> : null;
}
function BrowseDialog({
open,
kind = "dir",
initialPath,
onSelect,
onClose,
}: {
open: boolean;
/** `file` lists files too, and picking one is the only way to select. */
kind?: "dir" | "file";
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
const { lanes } = useWorkLanes();
const [listing, setListing] = useState<Listing | null>(null);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
const [selected, setSelected] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [editing, setEditing] = useState(false);
const [pathText, setPathText] = useState("");
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
key: "name",
dir: 1,
});
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
// The highest folder this user may open (a member's own folder answers no
// parent) — breadcrumbs above it are not links.
const [floor, setFloor] = useState<string | null>(null);
const [home, setHome] = useState<string | null>(null);
// An admin's "Server folder" tick (S3): browse the host account's home.
const [hostHome, setHostHome] = useState<string | null>(null);
const [onHost, setOnHost] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const crumbRef = useRef<HTMLDivElement>(null);
const typeAhead = useRef({ text: "", at: 0 });
const load = async (path?: string) => {
const load = async (
target?: string,
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
) => {
setError(null);
try {
setListing(await api.lanes.browse(path));
const next = await api.lanes.browse(target, kind === "file", hidden, host);
setListing(next);
setSelected(select);
setFilter("");
setEditing(false);
if (next.home) setHome(next.home);
setHostHome(next.hostHome ?? null);
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
if (push) {
setHistory((h) => {
if (h.stack[h.at] === next.path) return h;
const stack = [...h.stack.slice(0, h.at + 1), next.path];
return { stack, at: stack.length - 1 };
});
}
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
}
};
useEffect(() => {
if (open) void load(initialPath);
if (!open) return;
setHistory({ stack: [], at: -1 });
setCreating(false);
setOnHost(false);
void load(initialPath, { host: false });
// Only re-run when the modal actually opens - not on every initialPath
// keystroke in the field behind it.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -51,8 +183,171 @@ export function FolderBrowseModal({
const panel = useRef<HTMLDivElement>(null);
useDialog(open, panel, onClose);
const entries = useMemo(() => {
const needle = filter.trim().toLowerCase();
const list = (listing?.entries ?? []).filter(
(e) => !needle || e.name.toLowerCase().includes(needle)
);
const by = (a: Entry, b: Entry) =>
sort.key === "mtime"
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
sort.dir;
// Folders first, as every file picker lists them.
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
}, [listing, filter, sort]);
const places = useMemo(() => {
const seen = new Set<string>();
return lanes
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
}, [lanes]);
// A deep path keeps its LAST levels in view, like a native path bar.
useEffect(() => {
const el = crumbRef.current;
if (el) el.scrollLeft = el.scrollWidth;
}, [listing?.path, editing]);
useEffect(() => {
if (!selected) return;
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
(x) => x.dataset.path === selected
);
el?.scrollIntoView?.({ block: "nearest" });
}, [selected]);
if (!open) return null;
const finish = (picked: string) => {
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
setRecent(next);
writeStored(RECENT_KEY, next);
onSelect(picked);
onClose();
};
const activate = (entry: Entry) => {
if (entry.file) finish(entry.path);
else void load(entry.path);
};
const go = (delta: -1 | 1) => {
const at = history.at + delta;
const target = history.stack[at];
if (target === undefined) return;
void load(target, { push: false }).then((ok) => {
if (ok) setHistory((h) => ({ ...h, at }));
});
};
const goUp = () => {
if (!listing?.parent) return;
const from = listing.path;
void load(listing.parent, { select: from });
};
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
const target =
kind === "file"
? selectedEntry?.file
? selectedEntry.path
: null
: selectedEntry && !selectedEntry.file
? selectedEntry.path
: (listing?.path ?? null);
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
const idx = entries.findIndex((x) => x.path === selected);
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (e.altKey && e.key === "ArrowUp") return goUp();
const next =
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
if (entries[next]) setSelected(entries[next].path);
return;
}
if (e.key === "Home" || e.key === "End") {
e.preventDefault();
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
if (pick) setSelected(pick.path);
return;
}
if (e.key === "Enter") {
e.preventDefault();
if (selectedEntry) activate(selectedEntry);
else if (kind === "dir" && target) finish(target);
return;
}
if (e.key === "Backspace") {
e.preventDefault();
goUp();
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Typing jumps to the first name that starts with what was typed.
const now = Date.now();
const ta = typeAhead.current;
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
ta.at = now;
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
if (hit) setSelected(hit.path);
}
};
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
e.preventDefault();
setPathText(listing?.path ?? "");
setEditing(true);
} else if (e.altKey && e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.altKey && e.key === "ArrowRight") {
e.preventDefault();
go(1);
}
};
const createFolder = async () => {
const name = newName.trim();
if (!name || !listing) return;
setError(null);
try {
const made = await api.lanes.mkdir(listing.path, name);
setCreating(false);
setNewName("");
await load(listing.path, { push: false, select: made.path });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
// Switch between the admin's own home and the host account's, and open it.
const toggleHost = () => {
const next = !onHost;
setOnHost(next);
void load(undefined, { host: next });
};
const toggleHidden = () => {
const next = !showHidden;
setShowHidden(next);
writeStored(HIDDEN_KEY, next);
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
};
const iconBtn =
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
const placeBtn = (active: boolean) =>
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
}`;
const path = listing?.path ?? "";
const floorDepth = floor ? crumbs(floor).length : 0;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@@ -61,73 +356,362 @@ export function FolderBrowseModal({
>
<div
ref={panel}
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
onKeyDown={onPanelKey}
role="dialog"
aria-modal="true"
aria-label={t("browse.title")}
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
data-tour="folder-browse"
>
<div className="border-b border-border p-3">
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
{listing?.path || "…"}
{/* Toolbar: history, up, and the breadcrumb / path field. */}
<div className="flex items-center gap-1 border-b border-border p-2">
<button
type="button"
className={iconBtn}
onClick={() => go(-1)}
disabled={history.at <= 0}
title={t("browse.back")}
aria-label={t("browse.back")}
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
className={iconBtn}
onClick={() => go(1)}
disabled={history.at >= history.stack.length - 1}
title={t("browse.forward")}
aria-label={t("browse.forward")}
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={iconBtn}
onClick={goUp}
disabled={!listing?.parent}
title={t("browse.up")}
aria-label={t("browse.up")}
>
<ArrowUp className="h-4 w-4" />
</button>
{editing ? (
<input
autoFocus
value={pathText}
onChange={(e) => setPathText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
if (pathText.trim()) void load(pathText.trim());
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setEditing(false);
}
}}
onBlur={() => setEditing(false)}
aria-label={t("browse.editPath")}
spellCheck={false}
className="input min-w-0 flex-1 font-mono text-xs"
/>
) : (
<div
ref={crumbRef}
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
onDoubleClick={() => {
setPathText(path);
setEditing(true);
}}
>
{crumbs(path).map(([label, p], i, all) => {
const last = i === all.length - 1;
const locked = i + 1 < floorDepth;
return (
<span key={p} className="flex shrink-0 items-center">
{i > 0 && all[i - 1]?.[0] !== "/" && (
<span className="px-0.5 text-fg-muted">›</span>
)}
<button
type="button"
disabled={last || locked}
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
className={`rounded px-1 py-0.5 font-mono text-xs ${
last
? "font-medium text-fg-primary"
: locked
? "text-fg-muted"
: "text-fg-secondary hover:bg-surface-2"
}`}
>
{label}
</button>
</span>
);
})}
</div>
)}
<button
type="button"
className={iconBtn}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setPathText(path);
setEditing((v) => !v);
}}
title={t("browse.editPath")}
aria-label={t("browse.editPath")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex min-h-0 flex-1">
{/* Places */}
<nav
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
aria-label={t("browse.places")}
>
{home && (
<button
type="button"
className={placeBtn(path === home)}
onClick={() => void load(home)}
title={home}
>
<Home className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{t("browse.home")}</span>
</button>
)}
{places.length > 0 && (
<>
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
<Star className="h-3 w-3" />
{t("browse.workspaces")}
</div>
{places.map((pl) => (
<button
key={pl.path}
type="button"
className={placeBtn(path === pl.path)}
onClick={() => void load(pl.path)}
title={pl.path}
>
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">{pl.label}</span>
</button>
))}
</>
)}
{recent.length > 0 && (
<>
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
<Clock className="h-3 w-3" />
{t("browse.recent")}
</div>
{recent.map((r) => (
<button
key={r}
type="button"
className={placeBtn(path === r)}
onClick={() => void load(r)}
title={r}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{baseName(r)}</span>
</button>
))}
</>
)}
</nav>
{/* Listing */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border p-2">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (entries[0]) setSelected(entries[0].path);
listRef.current?.focus();
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
e.preventDefault();
activate(entries[0]);
}
}}
placeholder={t("browse.filter")}
aria-label={t("browse.filter")}
className="input min-w-0 flex-1 text-xs"
/>
</div>
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
{(["name", "mtime"] as const).map((key) => (
<button
key={key}
type="button"
onClick={() =>
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
}
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
>
{key === "name" ? t("browse.name") : t("browse.modified")}
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
</button>
))}
</div>
<div
ref={listRef}
tabIndex={0}
role="listbox"
aria-label={path}
onKeyDown={onListKey}
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
>
{error && (
<p role="alert" className="p-2 text-xs text-status-danger">
{error}
</p>
)}
{creating && (
<div className="flex items-center gap-2 px-2 py-1">
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (isSubmitEnter(e)) {
e.preventDefault();
void createFolder();
} else if (e.key === "Escape") {
e.preventDefault();
setCreating(false);
}
}}
placeholder={t("browse.newFolderName")}
aria-label={t("browse.newFolderName")}
className="input min-w-0 flex-1 text-xs"
/>
<button
type="button"
onClick={() => void createFolder()}
disabled={!newName.trim()}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.create")}
</button>
</div>
)}
{entries.map((entry) => {
const isSel = entry.path === selected;
return (
<div
key={entry.path}
data-path={entry.path}
role="option"
aria-selected={isSel}
onClick={() => setSelected(entry.path)}
onDoubleClick={() => activate(entry)}
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
}`}
>
{entry.file ? (
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
) : entry.isGitRepo ? (
<FolderGit2
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
/>
) : (
<FolderOpen
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
/>
)}
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<span
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
>
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
</span>
</div>
);
})}
{listing && entries.length === 0 && (
<p className="p-2 text-xs text-fg-muted">
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
</p>
)}
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-1">
{error && (
<p role="alert" className="p-2 text-xs text-status-danger">
{error}
</p>
)}
{listing?.parent && (
{/* Footer: what will be picked, and the actions. */}
<div className="border-t border-border p-2">
<div
// rtl so a long path loses its START to the ellipsis, not the folder.
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
title={target ?? undefined}
>
<bdi>{target ?? "…"}</bdi>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => load(listing.parent!)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
onClick={() => {
setNewName("");
setCreating(true);
}}
disabled={!listing}
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
>
<ArrowUp className="h-3.5 w-3.5" />
..
<FolderPlus className="h-3.5 w-3.5" />
{t("browse.newFolder")}
</button>
)}
{listing?.entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => load(entry.path)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
<span className="truncate">{entry.name}</span>
</button>
))}
{listing && listing.entries.length === 0 && !listing.parent && (
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
<button
type="button"
onClick={onClose}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
<Eye className="h-3.5 w-3.5" />
{t("browse.showHidden")}
</label>
{hostHome && (
<label
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
title={t("browse.hostFolderHint", { path: hostHome })}
>
<input
type="checkbox"
checked={onHost}
onChange={toggleHost}
data-testid="browse-host-folder"
/>
<Server className="h-3.5 w-3.5" />
{t("browse.hostFolder")}
</label>
)}
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
{t("browse.hint")}
</span>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!target}
onClick={() => target && finish(target)}
className="btn-primary text-xs disabled:opacity-50"
>
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,155 @@
/**
* @file InterruptedSessionsPrompt.tsx
* @description The app-wide "resume interrupted sessions?" dialog. A session
* whose pane died under it — the machine shut down, tmux was killed, the CLI
* crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones
* to resume (each in the first free tab of its workspace, with its run's
* model and effort) or resumes none. Either answer dismisses the whole list,
* so it is asked once; a resume that failed stays listed. Refetched on the
* `runs_interrupted` broadcast, so a tmux that dies under an open dashboard
* asks too. Resuming is the explicit click here, never the dialog opening.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type InterruptedRun } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { timeAgo } from "../../lib/format";
import { toast } from "../../lib/toast";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd;
export function InterruptedSessionsPrompt() {
const { t } = useTranslation("run");
const navigate = useNavigate();
const [items, setItems] = useState<InterruptedRun[]>([]);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
// A refetch keeps the user's ticks; a session not seen before starts ticked.
const seen = useRef(new Set<string>());
const load = useCallback(() => {
api.run
.interrupted()
.then((r) => {
const fresh = r.items.filter((i) => !seen.current.has(i.session_id));
fresh.forEach((i) => seen.current.add(i.session_id));
setItems(r.items);
setPicked((p) => {
const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id));
fresh.forEach((i) => next.add(i.session_id));
return next;
});
})
.catch(() => undefined); // a convenience: no prompt when it cannot be read
}, []);
useEffect(load, [load]);
useEffect(
() =>
eventBus.subscribe((msg) => {
if (msg.type === "runs_interrupted") load();
}),
[load]
);
const dismiss = async (sessionIds: string[]) => {
if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error);
};
const resumeNone = async () => {
if (busy) return;
setBusy(true);
await dismiss(items.map((i) => i.session_id));
setItems([]);
setBusy(false);
};
const resumePicked = async () => {
if (busy) return;
setBusy(true);
const failed = new Set<string>();
let first: { runId: string; laneId: number } | null = null;
for (const it of items.filter((i) => picked.has(i.session_id))) {
try {
const r = await startInFreeTabDetailed(it.lane_id, {
resumeSessionId: it.session_id,
...(it.model ? { model: it.model } : {}),
...(it.effort ? { effort: it.effort } : {}),
// Bypass is a per-run choice, never carried over.
...(it.permission_mode && it.permission_mode !== "bypassPermissions"
? { permissionMode: it.permission_mode }
: {}),
});
first ??= r;
} catch (err) {
failed.add(it.session_id);
toast.error(
t("interrupted.failed", {
name: label(it),
message: err instanceof Error ? err.message : String(err),
})
);
}
}
await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id)));
setItems((cur) => cur.filter((i) => failed.has(i.session_id)));
setBusy(false);
if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`);
};
const toggle = (id: string, on: boolean) =>
setPicked((p) => {
const next = new Set(p);
if (on) next.add(id);
else next.delete(id);
return next;
});
const count = items.filter((i) => picked.has(i.session_id)).length;
return (
<ConfirmModal
open={items.length > 0}
title={t("interrupted.title")}
message={t("interrupted.message")}
confirmLabel={t("interrupted.resume", { count })}
cancelLabel={t("interrupted.none")}
destructive={false}
busy={busy}
disabled={count === 0}
onConfirm={() => void resumePicked()}
onCancel={() => void resumeNone()}
>
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
{items.map((it) => (
<li key={it.session_id} className="rounded-md border border-border px-2 py-1.5">
<Checkbox
checked={picked.has(it.session_id)}
onChange={(on) => toggle(it.session_id, on)}
disabled={busy}
className="w-full"
labelClassName="min-w-0 flex-1"
label={
<span className="block min-w-0 text-left">
<span className="block truncate text-xs text-fg-primary">
{label(it)} · {it.tool}
</span>
<span className="block truncate text-[11px] text-fg-muted">
{it.session_name || it.prompt_preview || it.session_id}
{it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""}
</span>
</span>
}
/>
</li>
))}
</ul>
</ConfirmModal>
);
}
+8 -9
View File
@@ -57,7 +57,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
});
};
read();
const timer = setInterval(read, RUNTIME_REFRESH_MS);
const timer = setInterval(
() => document.visibilityState === "visible" && read(),
RUNTIME_REFRESH_MS
);
return () => {
alive = false;
clearInterval(timer);
@@ -88,7 +91,10 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
});
};
read();
const timer = setInterval(read, LOCKS_REFRESH_MS);
const timer = setInterval(
() => document.visibilityState === "visible" && read(),
LOCKS_REFRESH_MS
);
return () => {
alive = false;
clearInterval(timer);
@@ -98,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks;
}
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
// A ring: a surface-4 fill vanished on the light themes.
idle: "border border-fg-muted",
dead: "bg-status-danger",
};
export function since(sec: number | null): string {
if (sec === null) return "—";
if (sec < 60) return `${sec}s`;
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
export function LaneConflictView({
laneId,
session,
source,
path,
onResolved,
onEdit,
@@ -45,6 +46,8 @@ export function LaneConflictView({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
@@ -65,7 +68,7 @@ export function LaneConflictView({
setChoices([]);
setError(null);
api.lanes
.gitFile(gitTarget(laneId, session), path, "work")
.gitFile(gitTarget(laneId, session, source), path, "work")
.then((r) => {
if (!live) return;
setFile(
@@ -78,7 +81,7 @@ export function LaneConflictView({
return () => {
live = false;
};
}, [laneId, session, path]);
}, [laneId, session, source, path]);
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
@@ -102,15 +105,18 @@ export function LaneConflictView({
run(async () => {
if (!parts || !file?.sha) return;
await api.lanes.gitSaveFile(
gitTarget(laneId, session),
gitTarget(laneId, session, source),
path,
applyChoices(parts, choices).join("\n"),
file.sha
);
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
path,
side: "done",
});
});
const takeAll = (side: "ours" | "theirs") =>
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
const choose = (i: number, c: ConflictChoice | undefined) =>
setChoices((prev) => {
+156 -49
View File
@@ -10,9 +10,15 @@
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
* right for the case that matters (one edited span on a line), degrades to
* "the whole line changed" otherwise, and costs nothing.
* - Line pairing for split view happens here, on the rows the server already
* numbered. The server stays the single source of what changed; this file
* only decides how it looks.
* - Side by side is JetBrains-shaped and laid out here, on the rows the
* server already numbered: within a hunk each column lists only its own
* lines, one after another, so the sides drift apart past a change and a
* curved band in the middle gutter joins each block to its counterpart
* (`lib/diffConnectors.ts`). Only a hunk's END is padded, so the `@@`
* headers — and the two scrollers, kept in step 1:1 — stay level. Every
* split row is ROW_PX tall, which makes a band's y pure arithmetic. The
* server stays the single source of what changed; this file only decides
* how it looks.
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
@@ -38,6 +44,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
import { bandPath } from "../../lib/diffConnectors";
export type DiffLayout = "unified" | "split";
@@ -220,29 +227,28 @@ function emphasis(
};
}
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
type EmphPair = { del: [number, number]; add: [number, number] };
/** A split-view row: the index in `hunk.lines` each column shows, -1 for the
* padding that closes the shorter side of a hunk. */
type SplitRow = { left: number; right: number };
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
* index in `hunk.lines` (what the unified view renders by). */
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
type Analysed = { rows: SplitRow[]; emph: Map<number, [number, number]> };
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
// columns and every re-render of the same data share one pass. A refetch
// brings new objects and the old entries go with them.
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
/** Pair each run of removed lines with the run of added lines following it,
* carrying indices so emphasis lands without searching the hunk again. */
/** Pair each run of removed lines with the run of added lines following it
* for emphasis, keyed by line index so it lands without searching the hunk
* again; and pack each column's own lines into the split rows. */
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
const hit = analysed.get(hunk);
if (hit) return hit;
const lines = hunk.lines;
const rows: PairRow[] = [];
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
rows.push({ left: line, right: line, emph: null });
for (let i = 0; i < lines.length;) {
if (lines[i]!.type === "ctx") {
i += 1;
continue;
}
@@ -260,9 +266,18 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
emph.set(delStart + k, e.del);
emph.set(addStart + k, e.add);
}
rows.push({ left, right, emph: e });
}
}
const left: number[] = [];
const right: number[] = [];
lines.forEach((l, i) => {
if (l.type !== "add") left.push(i);
if (l.type !== "del") right.push(i);
});
const rows = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ({
left: left[i] ?? -1,
right: right[i] ?? -1,
}));
const out = { rows, emph };
analysed.set(hunk, out);
return out;
@@ -351,9 +366,14 @@ export type LaneDiffChange = {
/** First/last index in `hunk.lines` (unified). */
firstLine: number;
lastLine: number;
/** First/last split-view row. */
/** First split-view row (either side): the bar's arrows scroll here. */
firstRow: number;
/** The right column's row its review bar hangs under. */
lastRow: number;
/** Split-view rows `[from, to)` on each side; empty (`from === to`) is the
* point between two rows where a pure insertion / removal sits. */
left: [number, number];
right: [number, number];
};
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
@@ -363,23 +383,46 @@ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
const hit = blocksOf.get(hunk);
if (hit) return hit;
const out: LaneDiffChange[] = [];
const owner = new Map<LaneDiffLine, LaneDiffChange>();
hunk.lines.forEach((line, i) => {
if (line.type === "ctx") return;
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
if (!c) {
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
c = {
hunk,
block: out.length,
firstLine: i,
lastLine: i,
firstRow: -1,
lastRow: -1,
left: [0, 0],
right: [0, 0],
};
out.push(c);
}
c.lastLine = i;
owner.set(line, c);
});
analyseHunk(hunk).rows.forEach((r, i) => {
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
if (!c) return;
if (c.firstRow < 0) c.firstRow = i;
c.lastRow = i;
});
// A side's rows are its lines in order, so a block's rows there are the
// count of that side's lines before it, plus its own.
for (const c of out) {
let l = 0;
let r = 0;
hunk.lines.forEach((line, i) => {
if (i >= c.firstLine) return;
if (line.type !== "add") l += 1;
if (line.type !== "del") r += 1;
});
let dl = 0;
let dr = 0;
for (let i = c.firstLine; i <= c.lastLine; i += 1) {
if (hunk.lines[i]!.type === "del") dl += 1;
else dr += 1;
}
c.left = [l, l + dl];
c.right = [r, r + dr];
c.firstRow = Math.min(l, r);
// A pure removal hangs its bar under the right row its band points at.
c.lastRow = dr ? r + dr - 1 : Math.max(0, r - 1);
}
blocksOf.set(hunk, out);
return out;
}
@@ -397,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
* block's last line. A context rather than a prop because lines render from
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (change: LaneDiffChange) => ReactNode;
/** null = no bar; the anchors still mark each block for navigation. */
actions: ((change: LaneDiffChange) => ReactNode) | null;
changes: LaneDiffChange[];
go: (index: number) => void;
} | null>(null);
@@ -414,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
function HunkBar({ change }: { change: LaneDiffChange }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
if (!ctx?.actions) return null;
const index = ctx.changes.indexOf(change);
const total = ctx.changes.length;
const nav =
@@ -547,37 +591,36 @@ function longestInHunks(hunks: LaneDiffHunk[]) {
);
}
/** One column's row i of a hunk: that side's i-th line, or the padding
* that closes the hunk's shorter side. Always ROW_PX tall (see the header). */
function SplitLine({
row,
hunk,
i,
lang,
side,
style,
bar,
start,
}: {
row: PairRow;
hunk: LaneDiffHunk;
i: number;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffChange;
start?: LaneDiffChange;
}) {
const line = side === "left" ? row.left : row.right;
const { rows, emph } = analyseHunk(hunk);
const at = rows[i]![side];
const line = at < 0 ? null : hunk.lines[at]!;
const e = at < 0 ? undefined : emph.get(at);
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
<Row tint={rowTint(line?.type ?? null, !!e)} style={FIXED} anchor={anchor}>
<Gutter
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
commentable={side === "right"}
/>
<span className="whitespace-pre px-1">
{line ? (
<Code
text={line.text}
lang={lang}
emph={side === "left" ? row.emph?.del : row.emph?.add}
/>
) : null}
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
</span>
{bar && <HunkBar change={bar} />}
</Row>
@@ -598,11 +641,12 @@ function SplitColumn({
<>
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} />
{analyseHunk(hunk).rows.map((row, i) => (
<HunkHeader hunk={hunk} fixed />
{analyseHunk(hunk).rows.map((_, i) => (
<SplitLine
key={i}
row={row}
hunk={hunk}
i={i}
lang={lang}
side={side}
// One bar per change block: on the right (new) column only.
@@ -638,10 +682,10 @@ function SplitSlice({
) : (
<SplitLine
key={range.start + k}
row={analyseHunk(hunk).rows[i]!}
hunk={hunk}
i={i}
lang={lang}
side={side}
style={FIXED}
bar={side === "right" ? barAtRow(hunk, i) : undefined}
start={startAtRow(hunk, i)}
/>
@@ -657,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
@@ -677,11 +720,48 @@ function follow(src: HTMLElement, dst: HTMLElement) {
}
}
/** Both sides, each its own scroller but kept in step on both axes. */
/** The middle gutter's width: room for an S-curve to read as one. */
const GUTTER_PX = 36;
const BAND = {
add: "fill-status-success/20 stroke-status-success/60",
del: "fill-status-danger/20 stroke-status-danger/60",
mod: "fill-diff-mod/20 stroke-diff-mod/60",
} as const;
/** Every change block's band, in the columns' content coordinates: a hunk's
* header is item `h`, so its row r starts at (h + 1 + r) × ROW_PX. */
function bandsOf(items: Item[]) {
const out: { d: string; kind: keyof typeof BAND; top: number; bottom: number }[] = [];
items.forEach(({ hunk, i }, h) => {
if (i >= 0) return;
for (const c of changeBlocks(hunk)) {
const [y0, y1, z0, z1] = [...c.left, ...c.right].map((r) => (h + 1 + r) * ROW_PX) as [
number,
number,
number,
number,
];
const kind = y0 === y1 ? "add" : z0 === z1 ? "del" : "mod";
out.push({
d: bandPath(GUTTER_PX, y0, y1, z0, z1),
kind,
top: Math.min(y0, z0),
bottom: Math.max(y1, z1),
});
}
});
return out;
}
/** Both sides, each its own scroller but kept in step on both axes, and the
* gutter between them that draws each block's band, scrolled with them. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
const mid = useRef<HTMLDivElement>(null);
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
const bands = useMemo(() => bandsOf(items), [items]);
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
const win = useWindow(items.length);
@@ -695,8 +775,11 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst) return;
follow(src, dst);
if (mid.current) mid.current.scrollTop = src.scrollTop;
win.onScroll(src);
};
// Windowed, only the bands that reach the rendered rows are drawn.
const [from, to] = [win.range.start * ROW_PX, win.range.end * ROW_PX];
const column = (side: "left" | "right") =>
win.on ? (
@@ -717,11 +800,33 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
>
{column("left")}
</div>
<div
ref={mid}
data-testid="lane-diff-connectors"
aria-hidden
className="shrink-0 overflow-hidden border-x border-border-light bg-surface-1"
style={{ width: GUTTER_PX }}
>
{/* Taller than the columns by a scrollbar, so it can scroll as far. */}
<svg width={GUTTER_PX} height={items.length * ROW_PX + 24} className="block">
{bands
.filter((b) => b.bottom >= from && b.top <= to)
.map((b) => (
<path
key={`${b.top}:${b.d}`}
d={b.d}
data-kind={b.kind}
className={BAND[b.kind]}
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
<div
ref={right}
data-testid="lane-diff-split-right"
onScroll={() => sync("right")}
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
className="w-1/2 min-w-0 overflow-auto"
>
{column("right")}
</div>
@@ -826,7 +931,9 @@ export function LaneDiffView({
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
// Provided without actions too: every block's first row carries its
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
+82 -18
View File
@@ -19,8 +19,8 @@
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
* in, so a remote wanting a login opens a form here and the SAME action is
* resent with the typed credential — the token is never rendered back.
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
* asks through the themed confirm; the rows wrap in a narrow panel.
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
| "fetch"
| "pull"
| "push"
| "switch"
| "branch-create"
| "branch-delete"
| "merge"
| "publish";
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
@@ -95,6 +88,7 @@ function Btn({
export function LaneGitBar({
laneId,
session,
source,
git,
onChanged,
onNewLane,
@@ -103,6 +97,8 @@ export function LaneGitBar({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -165,7 +161,7 @@ export function LaneGitBar({
setBusy(action);
setError(null);
try {
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
@@ -218,7 +214,7 @@ export function LaneGitBar({
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
@@ -234,6 +230,60 @@ export function LaneGitBar({
await refreshBranches();
};
// Writes that leave this machine (push, publish) or delete a ref ask
// first. Asked HERE, at the click — never inside `run` — so the retry a git
// login sends is not asked twice.
const confirmRun = async (
opts: { title: string; message: string; destructive?: boolean },
action: Action,
body?: RunBody
) => {
if (await confirmDialog({ destructive: false, confirmLabel: opts.title, ...opts })) {
void run(action, body);
}
};
const push = async () => {
let ahead = git.ahead ?? 0;
if (git.upstream && ahead === 0) {
// The facts can be up to 30 s old (an agent may have just committed):
// ask git again before saying there is nothing to push — and never
// ask to confirm pushing zero commits.
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
const fresh = read?.available ? read : null;
ahead = fresh?.ahead ?? 0;
if (fresh?.upstream && ahead === 0) {
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
onChanged();
return;
}
}
return confirmRun(
git.upstream
? {
title: t("gitBar.push"),
message: t("gitBar.pushConfirm", {
count: ahead,
branch: git.branch,
upstream: git.upstream,
}),
}
: {
title: t("gitBar.publishBranch"),
message: t("gitBar.publishBranchConfirm", { branch: git.branch }),
},
"push"
);
};
const publish = () =>
confirmRun(
{
title: t("gitBar.publishApply"),
message: t("gitBar.publishConfirm", { branch: git.branch, url: url.trim() }),
},
"publish",
{ url: url.trim() }
);
const merge = async (branch: string) => {
const ok = await confirmDialog({
title: t("gitBar.merge"),
@@ -322,7 +372,11 @@ export function LaneGitBar({
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy || merging}
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
title={
merging
? t("gitBar.merging", { op: git.operation ?? "merge" })
: t("gitBar.pullHint")
}
onClick={() => run("pull")}
/>
<Btn
@@ -338,7 +392,7 @@ export function LaneGitBar({
? t("gitBar.publishBranchHint")
: t("gitBar.pushHint")
}
onClick={() => run("push")}
onClick={() => void push()}
/>
{!git.hasRemote && (
<Btn
@@ -362,7 +416,7 @@ export function LaneGitBar({
// beats a red box after it.
title={
merging
? t("gitBar.merging")
? t("gitBar.merging", { op: git.operation ?? "merge" })
: dirty > 0
? t("gitBar.switchDirty")
: t("gitBar.switchHint")
@@ -434,7 +488,17 @@ export function LaneGitBar({
busy={busy === "branch-delete"}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
title={t("gitBar.branchDeleteHint")}
onClick={() => void run("branch-delete", { branch: picked })}
onClick={() =>
void confirmRun(
{
title: t("gitBar.branchDelete"),
message: t("gitBar.branchDeleteConfirm", { branch: picked }),
destructive: true,
},
"branch-delete",
{ branch: picked }
)
}
/>
{dirty > 0 && (
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
@@ -474,7 +538,7 @@ export function LaneGitBar({
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() });
if (isSubmitEnter(e) && url.trim()) void publish();
}}
// The server decides which URLs are acceptable (https without a
// token, ssh, user@host:path); this only names the two usual ones.
@@ -488,7 +552,7 @@ export function LaneGitBar({
busy={busy === "publish"}
disabled={!url.trim()}
title={t("gitBar.publishHint")}
onClick={() => void run("publish", { url: url.trim() })}
onClick={() => void publish()}
/>
</div>
)}
+16 -5
View File
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string
session?: string,
/** Read that extra folder's repo instead (`?source=`). */
source?: number
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
@@ -54,7 +56,7 @@ export function useLaneGitFacts(
let alive = true;
const read = () => {
api.lanes
.git(gitTarget(laneId, session))
.git(gitTarget(laneId, session, source))
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
@@ -65,12 +67,15 @@ export function useLaneGitFacts(
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
const timer = setInterval(
() => document.visibilityState === "visible" && read(),
GIT_REFRESH_MS
);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId, session, nonce]);
}, [laneId, session, source, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
@@ -319,20 +324,23 @@ export function LaneGitCounts({
export function LaneGitRailCounts({
laneId,
session,
source,
onShowAll,
}: {
laneId: number;
session?: string;
source?: number;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
const [git] = useLaneGitFacts(laneId, session);
const [git] = useLaneGitFacts(laneId, session, source);
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
}
export function LaneGitHeader({
laneId,
session,
source,
git,
onChanged,
onNewLane,
@@ -340,6 +348,8 @@ export function LaneGitHeader({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
@@ -371,6 +381,7 @@ export function LaneGitHeader({
<LaneGitBar
laneId={laneId}
session={session}
source={source}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
+25 -23
View File
@@ -4,20 +4,21 @@
* the sidebar's **New session** row). Every item is something the row already
* does: open it the way its click does (a live pane attaches, an ended session
* opens its conversation — never a resume), open a live one in a split,
* rename / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
* after the same confirm for an AI tab, which may be mid-turn): **Sleep** stops
* it and keeps the session listed to resume later, **Close tab** stops it and
* archives the session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import type { RunHandle } from "../../lib/api";
import type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { canSleep, endRuns } from "../../lib/stopSession";
export function SessionMenu({
laneId,
@@ -29,6 +30,7 @@ export function SessionMenu({
onRename,
onArchive,
onUnarchive,
onPin,
}: MenuPoint & {
laneId: number;
session: Session | null;
@@ -38,6 +40,8 @@ export function SessionMenu({
onRename?: () => void;
onArchive?: () => void;
onUnarchive?: () => void;
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
onPin?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -47,24 +51,12 @@ export function SessionMenu({
onClose();
fn();
};
const closeTab = async () => {
// Stop archives the session; Sleep keeps it listed to resume later
// (`endRuns` asks first for an AI pane, which may be mid-turn).
const endTab = (sleep: boolean) => {
if (!run) return;
onClose();
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
} catch (err) {
toast.error(err);
}
void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
};
return (
@@ -84,6 +76,11 @@ export function SessionMenu({
</MenuItem>
)}
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
{onPin && session && (
<MenuItem onSelect={pick(onPin)}>
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
</MenuItem>
)}
{session && (
<>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
@@ -97,8 +94,13 @@ export function SessionMenu({
{(run || onArchive || onUnarchive) && <MenuSeparator />}
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
{canSleep(run) && (
<MenuItem testId="session-menu-sleep" onSelect={() => endTab(true)}>
{t("tabs.sleep")}
</MenuItem>
)}
{run && (
<MenuItem danger onSelect={() => void closeTab()}>
<MenuItem danger onSelect={() => endTab(false)}>
{t("tabs.close")}
</MenuItem>
)}
+15 -6
View File
@@ -108,7 +108,7 @@ export function SessionReplay({
? { runId: holder.id, laneId: lane.id, held: true }
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
onResumed(r.runId, r.laneId);
// Held already: the prompt reached nothing yet — paste it there.
@@ -121,19 +121,27 @@ export function SessionReplay({
};
return (
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
<div
data-testid="session-replay"
data-prompt-drop
className="flex min-h-0 flex-1 flex-col gap-2"
>
{session && (
<div className="flex justify-end">
<SessionResumeButton session={session} />
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} />
<ConversationView
sessionId={sessionId}
fill
ended={!livePane && !!session && session.status !== "active"}
/>
</div>
{livePane ? (
<div
data-testid="session-replay-live"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
@@ -148,7 +156,7 @@ export function SessionReplay({
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
@@ -160,7 +168,7 @@ export function SessionReplay({
</button>
</div>
) : (
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
@@ -173,6 +181,7 @@ export function SessionReplay({
rows={2}
slash={tool === "claude"}
fileCwd={session?.cwd || lane.cwd}
lane={lane.id}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -52,6 +53,7 @@ export function SessionResumeButton({
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { lanes } = useLanes();
const { canEditLane } = usePermissions();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false);
@@ -77,7 +79,8 @@ export function SessionResumeButton({
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null;
// Server mode: an analyst may resume only in a workspace of their own.
if (!lane || !canEditLane(lane) || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => strip(a) === strip(b);
// Its own worktree counts too: the server resumes it there (`claude
@@ -0,0 +1,571 @@
/**
* @file WorkspaceContext — a workspace's context: Instructions, Memory
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
* workspace home's right column and, `compact`, folded at the bottom of the
* session view's Changes panel. Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
* same form clones related repos beside the workspace (`CloneSourcesForm`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
import { copyText } from "../../lib/clipboard";
import { dragProjectPath } from "../../lib/promptAttach";
import { useDialog } from "../../hooks/useDialog";
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { MarkdownPreview } from "../MarkdownPreview";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { PathPickButton } from "../PathPickButton";
import { CloneSourcesForm } from "./CloneSourcesForm";
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
return <ContextBody key={props.lane.id} {...props} />;
}
function ContextBody({
lane,
onSourcesChange,
sourcesVersion = 0,
compact = false,
}: {
lane: Lane;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
/** Bumped when the folder list changed elsewhere: the lists re-read. */
sourcesVersion?: number;
/** The Changes panel's narrow fold: no card, tighter rows. */
compact?: boolean;
}) {
const { t } = useTranslation("lanes");
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const fileInput = useRef<HTMLInputElement>(null);
const [ctxDrag, setCtxDrag] = useState(false);
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const sourceCtx = useContextMenu<LaneSource>();
useEffect(() => {
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch((err) => live && setError(message(err)));
api.lanes
.memory(lane.id)
.then((r) => live && setMemory(r.files))
.catch(() => live && setMemory([]));
return () => {
live = false;
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (!live) return;
const p = projects.find((x) => x.workspaces.includes(lane.id));
const own = new Set((view?.sources ?? []).map((s) => s.path));
setSuggestions(
!p || !prefs.showUntracked
? []
: p.worktrees
.filter((w) => w.workspace_id === null && !own.has(w.path))
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
.map((w) => w.path)
);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
useEffect(() => {
if (!sourcesVersion) return;
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch(() => undefined);
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
}, [sourcesVersion]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null);
try {
const next = await fn();
setView(next);
onSourcesChange?.(next);
return true;
} catch (err) {
setError(message(err));
return false;
}
};
const addFiles = async (picked: File[]) => {
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
};
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const card = compact
? "border-b border-border/60 px-1 py-2 last:border-b-0"
: "border-b border-border px-4 py-4 last:border-b-0";
const heading = compact
? "text-xs font-medium text-fg-secondary"
: "text-sm font-medium text-fg-primary";
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
return (
<>
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
{error && (
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
{error}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
>
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={compact ? 4 : 6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.memory")}</h3>
<button
type="button"
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section
data-testid="workspace-context-files"
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
// Files dragged in from the OS are added like the + button's pick.
onDragOver={(e) => {
if (!view || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setCtxDrag(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
}}
onDrop={(e) => {
setCtxDrag(false);
if (!view || e.dataTransfer.files.length === 0) return;
e.preventDefault();
void addFiles([...e.dataTransfer.files]);
}}
>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={(e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
void addFiles(picked);
}}
/>
</div>
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
// Dragged onto a prompt box, it is attached as `@<path>`.
draggable
onDragStart={(e) => dragProjectPath(e, f.path)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li
className="flex items-start gap-2 text-xs"
draggable
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
>
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
draggable
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
{folderOpen && (
<CloneSourcesForm
laneId={lane.id}
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
onAdded={(next) => {
setView(next);
onSourcesChange?.(next);
}}
/>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex shrink-0 items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
<div
role="tablist"
aria-label={t("home.memory")}
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
// APG tabs: arrows move (and select), one tab stop.
onKeyDown={(e) => {
const n = memory.length;
const to =
e.key === "ArrowRight"
? (i + 1) % n
: e.key === "ArrowLeft"
? (i - 1 + n) % n
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (to === null) return;
e.preventDefault();
setMemoryTab(to);
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
}}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</button>
))}
</div>
{memory[memoryTab] && (
<div
role="tabpanel"
id="workspace-memory-panel"
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
className="min-h-0 flex-1 overflow-y-auto"
>
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
</>
);
}
+89 -468
View File
@@ -5,8 +5,10 @@
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
* the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
* by default; its last row, **Open terminal**, is no AI — it starts a plain
* 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
@@ -14,39 +16,30 @@
* **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,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ArchiveRestore,
ShieldAlert,
FileText,
Folder,
GitBranch,
MoreHorizontal,
Pencil,
ArrowUp,
Plus,
X,
SquareTerminal,
} from "lucide-react";
import {
api,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile,
type LaneSource,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
@@ -55,16 +48,16 @@ import {
import type { Lane, Project, Session } from "../../lib/types";
import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ChatAddMenu } from "../run/ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
import { menuPoint, useContextMenu } from "../ContextMenu";
import {
liveRunFor,
SessionFold,
@@ -82,14 +75,8 @@ export {
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -101,6 +88,9 @@ type HomeProps = Parameters<typeof HomeBody>[0];
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
* model, an open folder form, a late upload's answer) belongs to ONE
* workspace, and the page swaps `lane` without unmounting. */
/** The AI menu row that opens a terminal; no catalog id can collide (ids are slugs). */
const OPEN_TERMINAL = "__terminal__";
export function WorkspaceHome(props: HomeProps) {
return <HomeBody key={props.lane.id} {...props} />;
}
@@ -109,17 +99,23 @@ function HomeBody({
lane,
onMenuAction,
onRenamed,
onSourcesChange,
sourcesVersion = 0,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
/** Bumped when the folder list changed elsewhere (the Changes panel's
* Close repository): the Folder list re-reads. */
sourcesVersion?: number;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
@@ -143,6 +139,7 @@ function HomeBody({
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const pickFiles = useRef<(() => void) | null>(null);
const [proxy, setProxy] = useSessionProxy();
const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
@@ -168,7 +165,8 @@ 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;
const [mode, setMode] = useState<PermissionMode>("auto");
// The account's own default (first-run setup) — never bypass.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
@@ -191,21 +189,9 @@ function HomeBody({
// A Recents row being renamed (its session id) and the draft.
const [renamingId, setRenamingId] = useState<string | null>(null);
const [nameDraft, setNameDraft] = useState("");
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
@@ -234,25 +220,6 @@ function HomeBody({
`${runKey}:${version}`
);
useEffect(() => {
let live = true;
setView(null);
setMemory(null);
setEditing(null);
setError(null);
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch((err) => live && setError(message(err)));
api.lanes
.memory(lane.id)
.then((r) => live && setMemory(r.files))
.catch(() => live && setMemory([]));
return () => {
live = false;
};
}, [lane.id]);
// The repository this workspace belongs to (Settings → Projects used to
// show this): read once per visit, from the server's cached project list.
const [project, setProject] = useState<Project | null>(null);
@@ -269,39 +236,21 @@ function HomeBody({
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (!live) return;
const p = projects.find((x) => x.workspaces.includes(lane.id));
const own = new Set((view?.sources ?? []).map((s) => s.path));
setSuggestions(
!p || !prefs.showUntracked
? []
: p.worktrees
.filter((w) => w.workspace_id === null && !own.has(w.path))
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
.map((w) => w.path)
);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
const openTerminal = async () => {
if (startingRef.current) return;
startingRef.current = true;
setStarting(true);
setError(null);
try {
setView(await fn());
return true;
const runId = await startInFreeTab(lane.id, { shell: true });
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
setError(message(err));
return false;
} finally {
startingRef.current = false;
setStarting(false);
}
};
@@ -328,7 +277,14 @@ function HomeBody({
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
// Here, not only through the effect: the navigate below unmounts this
// home in the same batch, so the effect never sees the empty prompt.
try {
localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: nothing was kept */
}
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
@@ -409,10 +365,7 @@ function HomeBody({
? t("run:hint.sandboxWarning")
: null;
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const name = workspaceName(lane);
const card = "border-b border-border px-4 py-4 last:border-b-0";
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
@@ -421,7 +374,7 @@ function HomeBody({
data-testid="workspace-home"
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
>
<div className="min-w-0 flex-1 basis-[28rem]">
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
<p className="text-xs text-fg-muted">
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
</p>
@@ -500,31 +453,51 @@ function HomeBody({
rows={3}
slash={claude}
fileCwd={lane.cwd}
lane={lane.id}
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
bare
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={lane.cwd}
laneId={lane.id}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
<div data-testid="workspace-home-tool">
<Select<string>
variant="ghost"
label={t("home.ai")}
value={tool}
onChange={(v) => {
// Not a tool: an action. The AI chosen for the prompt stays.
if (v === OPEN_TERMINAL) return void openTerminal();
setTool(v);
setBypass(false);
}}
options={toolOptions.map((id) => ({
value: id,
label: aiTools.label(id),
icon: (
<AiToolIcon
tool={{ id, label: aiTools.label(id) }}
className="h-4 w-4 !p-0.5"
/>
),
}))}
options={[
...toolOptions.map((id) => ({
value: id,
label: aiTools.label(id),
icon: (
<AiToolIcon
tool={{ id, label: aiTools.label(id) }}
className="h-4 w-4 !p-0.5"
/>
),
})),
{
value: OPEN_TERMINAL,
label: t("home.openTerminal"),
icon: <SquareTerminal className="h-4 w-4 text-fg-muted" />,
divider: true,
},
]}
/>
</div>
<div className="ml-auto flex items-center gap-1">
@@ -737,6 +710,16 @@ function HomeBody({
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
</button>
)}
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
<button
type="button"
data-testid="workspace-recents-less"
onClick={() => setRecentLimit(RECENT_PAGE)}
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
<div className="mt-2">
<SessionFold
testId={`workspace-archived-${lane.id}`}
@@ -751,345 +734,11 @@ function HomeBody({
</div>
</div>
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className="card w-80 shrink-0 self-start p-0"
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
<button
type="button"
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={async (e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
}}
/>
</div>
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li className="flex items-start gap-2 text-xs">
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex shrink-0 items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
<div
role="tablist"
aria-label={t("home.memory")}
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
// APG tabs: arrows move (and select), one tab stop.
onKeyDown={(e) => {
const n = memory.length;
const to =
e.key === "ArrowRight"
? (i + 1) % n
: e.key === "ArrowLeft"
? (i - 1 + n) % n
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (to === null) return;
e.preventDefault();
setMemoryTab(to);
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
}}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</button>
))}
</div>
{memory[memoryTab] && (
<div
role="tabpanel"
id="workspace-memory-panel"
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
className="min-h-0 flex-1 overflow-y-auto"
>
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
<WorkspaceContext
lane={lane}
onSourcesChange={onSourcesChange}
sourcesVersion={sourcesVersion}
/>
{recentCtx.menu && (
<SessionMenu
@@ -1107,34 +756,6 @@ function HomeBody({
onArchive={() => void archiveSession(recentCtx.menu!.item)}
/>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}
+18 -11
View File
@@ -8,13 +8,14 @@
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring.
* dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly`
* row (another user's workspace, for an analyst) has no menu, rename or `+`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types";
import { isSubmitEnter } from "../../lib/keys";
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
export function WorkspaceRow({
lane,
index,
readOnly,
selected,
expanded,
renaming,
@@ -63,6 +65,9 @@ export function WorkspaceRow({
lane: Lane;
/** Position in the list — the unit both reorder paths speak in. */
index: number;
/** Set (to its tooltip) for a workspace this user may only read: no menu,
* no rename, no new session. */
readOnly?: string;
selected: boolean;
expanded: boolean;
renaming: boolean;
@@ -82,7 +87,12 @@ export function WorkspaceRow({
const openMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onMenu(e.clientX, e.clientY);
if (!readOnly) onMenu(e.clientX, e.clientY);
};
const startRename = () => {
if (readOnly) return;
setDraft(lane.title || "");
onRenameStart();
};
const Chevron = expanded ? ChevronDown : ChevronRight;
const hoverBtn =
@@ -140,22 +150,18 @@ export function WorkspaceRow({
aria-current={selected ? "true" : undefined}
// A double-click's second click renames instead of opening again.
onClick={(e) => e.detail < 2 && onSelect()}
onDoubleClick={() => {
setDraft(lane.title || "");
onRenameStart();
}}
onDoubleClick={startRename}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
setDraft(lane.title || "");
onRenameStart();
startRename();
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${t("row.hint")}`}
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
@@ -163,7 +169,8 @@ export function WorkspaceRow({
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
</button>
)}
{!renaming && (
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
{!renaming && !readOnly && (
<>
<button
type="button"

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