Compare commits

...

69 Commits

Author SHA1 Message Date
nntrivi2001 649f0599f8 chore: v2.8.18 — pasted file-manager copies attach by path locally, copy/IME/stale-reply fixes, react-router 7, full OpenAPI 2026-10-22 13:58:46 +07:00
nntrivi2001 8d2765313c fix(chat): a file-manager copy pasted on a local browser attaches by its own path
Chrome gives a PASTE of a file manager's copy (Ctrl+C in Nautilus, Ctrl+V in
the chat) the files and their paths as plain text, but no text/uri-list —
only a drag carries that — so localCandidates found no path and every such
file was uploaded, and refused past the upload limit. Plain-text paths (or
file:// lines) whose names match the pasted files one for one are now
candidates too; sameFilesHere still has the server confirm each is the same
file (size) on this machine, so server mode and remote browsers upload as
before. The prompt box also attaches a file:// text paste with no file by
path, as the terminal already did (copiedFilePaths moved to promptAttach).
2026-10-22 13:47:17 +07:00
nntrivi2001 57f02f0d89 docs: OpenAPI and API.md cover every route; drop removed endpoints; guard config writes
- openapi-extra/run.js describes tmux pane runs only: the removed
  POST /api/run/{id}/message and stream-json run_stream/run_input_ack are gone.
- 108 operations added to OpenAPI (lanes, run, settings, reports, cc-config,
  pets, ai-proxies, tool-profiles, ai-tools, snapshots, locks, ...);
  openapi.yaml regenerated.
- docs/API.md: 74 routes added, 4 endpoints that no longer exist removed.
- Role is `developer`, not `member` (CLI.md, DATABASE.md, OVERVIEW.md);
  `agenthub update` -> `update-check`; help lists `server install-system|tools`
  and `users provision`; ARCHITECTURE.md's stale paths and names fixed.
- cc-config/codex-config writes carry sameOriginGuard (an MCP server or plugin
  is code Claude runs later), with a test; `command -v` takes its name as $1.
2026-10-22 12:17:18 +07:00
nntrivi2001 50ac0f0daf fix(client): copies over plain HTTP, Vietnamese IME Enter, stale replies; react-router 7
- Every Copy goes through copyText: on http://<lan-ip> navigator.clipboard
  is undefined, so the push token (shown once), snapshot links, tool paths and
  hosts copied nothing, silently or with a TypeError.
- Inputs that submit on Enter use isSubmitEnter, so committing an IME
  composition (Vietnamese) no longer saves a half-typed pet name, jumps the
  settings search, or submits a cut-off plugin/MCP/marketplace spec.
- LaneChangesPanel, Workspace features/proof and AiToolConfig drop a reply
  that lands after the next request started: opening file B before file A's
  read returned showed (and let the editor save) A's text under B's header.
- Error-state Retry buttons spin through useSpin; ImageLightbox uses useDialog.
- react-router-dom 6.30 -> 7.18.4 (GHSA-wrjc-x8rr-h8h6, GHSA-337j-9hxr-rhxg);
  screen snapshots gain only Link's data-discover attribute.
- StatusBar test follows v2.8.17's "Settings → AI tools → Profiles" text.
2026-10-22 12:17:18 +07:00
nntrivi2001 fa74cce3fc chore: v2.8.17 — every AI tool's login profiles in one Settings section, linked to the account pool 2026-10-22 11:11:43 +07:00
nntrivi2001 3113479cd2 feat(settings): every AI tool's login profiles in one Settings section, linked to the account pool
Settings → AI tools → Profiles holds one tab per tool: Claude Code (the
cc-profiles panel, moved out of the Claude Code page's modal — its button
now links here), Codex (the whole $CODEX_HOME/auth.json) and Oh My Pi
(`omp auth export` / `omp auth import -`, secrets on stdin; omp's credential
tables are emptied first because import merges, and a failed import puts
the outgoing set back). Antigravity is listed as not switchable: agy keeps
its login in the OS keyring.

server/lib/tool-profiles.js + /api/tool-profiles keep cc-profiles' rules
(one login = one profile, the outgoing login is re-saved before a switch,
an unsaved one refuses it, the live login's profile is not deletable) and
never return a token; /tool-profiles joins ADMIN_WRITE_PREFIXES and
STAFF_ONLY_PREFIXES, writes are same-origin only.

The account pool takes a saved Codex profile too (moved in like a Claude
one, GET /api/accounts/profiles?tool=codex). Each Claude/Codex profile has
a "Move to pool" link (/admin?poolAdd=&poolProfile= opens the pool's Add
dialog on it), Settings shows the pool's size, and the pool card links back.
2026-10-22 11:11:38 +07:00
nntrivi2001 4eb7734ab9 chore: v2.8.16 — Oh My Pi login sharing: a pane's key is listed before it starts
omp's gateway re-reads its clients file only every 10 s, and an omp that
starts while its key is refused fails model discovery once and hangs for
good. omp-share.js now keeps four keys queued in the clients file (hashes
only; the keys in <data dir>/omp-share/tokens.json, 0600): a new pane takes
one already listed (AGENTHUB_OMP_KEY), a fresh one joins the queue, and a
gone pane's key leaves at the next sync.
2026-10-22 10:29:55 +07:00
nntrivi2001 750d74a2af chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as
  the host on loopback; a member's omp pane reaches it as a scoped client keyed
  by its pane token (clients file = live omp panes only), runs on
  --model agenthub/<model>, never holds a credential.
- omp catalog: --allow-home, so a workspace at the account's home keeps its
  sessions there (they were recorded under a temp dir and dropped).
- Settings → Appearance → Open Markdown as: Preview (default) or Raw, for
  Files/Changes, the config viewer and a chat's file panel.
- First-login password change and password rules, avatar URLs, Artifacts
  "Mine" includes installed shared skills/plugins, a member's own hook pages.
2026-10-22 10:04:30 +07:00
nntrivi2001 8a6dc6a29e chore: v2.8.14 — Artifacts storefront and Uninstall, terminal chat for every tool, Discard all 2026-10-21 16:10:30 +07:00
nntrivi2001 c042519bc9 feat(artifacts): storefront layout and Uninstall for installed skills/plugins
- Artifacts page laid out like an app store: hero search, category chips,
  shelves by kind, app-icon cards with one primary pill and a detail sheet
- POST /api/artifacts/:id/uninstall: a skill's copies in ~/.claude/skills and
  ~/.agents/skills are moved to ~/.claude/agenthub-replaced-skills/
  (-uninstalled-<time>), never deleted; a plugin runs
  `claude plugin uninstall --scope user --keep-data` as the caller;
  ENOTINSTALLED when nothing is there; allowed while installs are off
- Uninstall on the card, the ⋯ menu and the detail sheet, after a confirm
2026-10-21 16:10:30 +07:00
nntrivi2001 383b9ae231 feat(chat): terminal chat parity for every tool; Desktop-like artifacts
- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
2026-10-21 15:44:17 +07:00
nntrivi2001 aee4fc190e feat: Discard all, claude-style image pastes, large local drops, per-account / list
- Changes panel: **Discard all** beside Commit discards every non-conflicted
  file in one `discard`, after a dialog naming how many untracked files go.
- Terminal: a `claude` pane gets dropped/pasted images as bare paths (shown as
  `[Image #n]`) and other files as `@` mentions (`claudePastes`); a local
  browser's copied `file://` paths and text-drag file lists paste by path.
- `POST /api/run/drop-file`: a loopback browser outside server mode may drop
  up to 2 GB, streamed to disk; remote stays 20 MB.
- cc-config skills/commands/plugins read the USER scope of the account the
  pane runs as (S3 member home by `cwd`), never another user's or the host's
  for a non-admin without an account; the run form passes its cwd to plugins.
2026-10-21 15:44:06 +07:00
nntrivi2001 0547488f13 chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files
- Prompt: attach a file on this computer by path (native picker), clearer too-large messages
- Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills
- Files: batch upload of small files in one extract
- AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
2026-10-20 16:44:55 +07:00
nntrivi2001 49f87c4da3 chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
2026-10-19 17:19:41 +07:00
nntrivi2001 1a6b550e89 feat(files): New → Clone repository clones into the open folder
The File Explorer's New menu (and its right-click menu) gains Clone
repository: it clones a URL into the folder it has open — of the user's own
home or of a workspace's own folder — through POST /api/lanes/clone with
that folder as `parent`, so the URL allowlist, the own-folder check and the
folder account's own git identity all apply, and opens a workspace on the
clone. ECREDENTIALS asks for a login, ESSHAUTH points to Settings → Git.
GET /api/homes now gives `me` its absolute `dir`. Keys typed in the dialog
never reach the explorer's shortcuts (Backspace / Delete).
2026-10-19 17:13:26 +07:00
nntrivi2001 f8d07dab61 feat(ai-tools): omp shared + OpenCode/omp sessions, tokens, subagents; Grok hooks; resume by id
- omp-usage.js / opencode-usage.js: poll each CLI's own session store into
  sessions (tool omp/opencode), token_usage per model and subagent agents;
  transcript routes read them (omp journals, OpenCode's opencode.db).
- Grok runs the Claude hooks in ~/.claude/settings.json: its hooks are now
  recognised (hookEventName + workspaceRoot), tagged tool=grok, and their
  transcript_path dropped instead of being parsed as a Claude transcript.
- Catalog `resumeById`: omp/Grok/Cursor `--resume <id>`, OpenCode
  `--session <id>`; session ids may carry `_`. The client offers Resume for
  any tool that resumes by id.
- Kimi Code and GitHub Copilot leave the catalog (their session stores could
  not be verified).
2026-10-19 16:49:59 +07:00
nntrivi2001 0434ffc750 fix(tmux): scrolling a pane no longer freezes typing
The wheel puts a pane into tmux's copy-mode, which took every typed key as
a scrollback command and painted a yellow [0/0] box in the corner. In
AgentHub's own panes (every other session on the server keeps tmux's
defaults, restored verbatim):

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:31:30 +07:00
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
453 changed files with 68497 additions and 7203 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
}
]
}
+13 -1
View File
@@ -72,9 +72,21 @@
# DASHBOARD_SESSION_SYNC_MS=30000
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
# to 64 MB), PRAGMA optimize, the opt-in retention below and a bounded
# incremental_vacuum. 0 turns it off. Never a full VACUUM (that is Settings →
# Data → Compact, on a click).
# DASHBOARD_DB_MAINTAIN_MS=3600000
# Automatic retention (OFF unless set): finished sessions that started more
# than N days ago (1–3650) are purged by the hourly upkeep. Usually set from
# Settings → Data → Session cleanup, which writes it to the settings file.
# AGENTHUB_RETENTION_DAYS=90
# Update check interval (ms, default 3600000 = 1 h, minimum 60000). A tick
# with no browser connected is skipped; the next browser to connect gets one.
# DASHBOARD_UPDATE_CHECK=0 turns the scheduled check off.
# DASHBOARD_UPDATE_CHECK_INTERVAL_MS=3600000
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
+1
View File
@@ -11,3 +11,4 @@ client/package-lock.json
*.md
*.yml
*.yaml
server/vendor/
+2 -3
View File
@@ -23,9 +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.
- Full suites take 5–6 minutes, so while iterating run only what the change touches: `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>`). Run the full suite once before committing, and always when the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib) — the per-screen snapshots catch that.
- 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
+46 -35
View File
File diff suppressed because one or more lines are too long
+18 -13
View File
File diff suppressed because one or more lines are too long
+407 -82
View File
@@ -1,6 +1,6 @@
# Installation
A step-by-step guide to get AgentHub — the local control surface for Claude Code agents — up and running on your machine, with optional sections for importing history and running in a container.
The one install-and-setup guide for AgentHub — the local control surface for Claude Code agents: the plugin install, a checkout install, configuration, the database, importing history, and troubleshooting. (`SETUP.md` is only a pointer here now.)
## Fastest path — install it as a Claude Code plugin
@@ -17,17 +17,20 @@ on PATH — no clone, no `npm run setup`, no
result and `/agenthub-open` to build the UI and get the URL.
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
the server stores data through `node:sqlite`). Full behavior, including the
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
the server stores data through `node:sqlite`), plus `git` and `tmux` from
[Step 0](#step-0--system-packages). Full behavior, including the uninstall
cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md). If
the dashboard never comes up, see
[Plugin bootstrap never comes up](#plugin-bootstrap-never-comes-up).
Follow the steps below instead when you want a checkout — to develop the
dashboard, run it from source, or stay on Node 20.
dashboard or run it from source.
## Requirements
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ (22+ recommended) | Required for server and client |
| Node.js | **22.5+** | Required for server and client (`node:sqlite`). Node 20 is end-of-life and unsupported |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
@@ -76,7 +79,7 @@ Check it:
```bash
tmux -V # e.g. tmux 3.4
git --version
node -v # v20.x / v22.x / v24.x
node -v # v22.5+ / v24.x
```
Once the server is up you can ask it directly — this is the same probe the
@@ -113,8 +116,8 @@ brew install node git tmux # Node >= 22.5 for the plugin install (nvm work
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
```
- **Node:** a plugin install needs **>= 22.5** (it stores data through
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
- **Node:** **>= 22.5** (the plugin stores data through `node:sqlite`; a
checkout needs the same). The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
@@ -172,24 +175,20 @@ cd AgentHub
## Step 2 — Install dependencies
```bash
npm run setup
npm run setup # or: make setup
```
This installs all server and client dependencies, plus the VS Code extension, and links the `agenthub` CLI.
This runs `npm install` (whose `postinstall` hook installs the client's
dependencies in `client/`), installs `client/` once more explicitly, and links
the `agenthub` CLI (`npm link`; if that has no permission it prints how to link
it by hand). A plain `npm install` is enough to build and run the dashboard —
only the CLI link is missing.
A plain root install already covers server **and** client — a `postinstall` hook installs the client dependencies automatically, so this alone is enough to build and run the dashboard:
With `--ignore-scripts` the `postinstall` hook is skipped; install the client
yourself with `npm --prefix client install`.
```bash
npm install
```
`npm run setup` additionally installs the VS Code extension and links the `agenthub` CLI. (If you install with `--ignore-scripts`, the `postinstall` hook is skipped — run `cd client && npm install` manually in that case.)
Or via Makefile:
```bash
make setup
```
`better-sqlite3` gyp/compile warnings here are harmless — see
[Troubleshooting](#better-sqlite3-errors-during-npm-install--npm-run-setup).
---
@@ -234,7 +233,7 @@ After starting a Claude Code session, you should see:
- **Dashboard** — stats updating in real-time
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
If nothing appears after 30 seconds, see [Troubleshooting](#no-sessions-appearing-after-starting-claude-code).
### PWA install (optional)
@@ -246,57 +245,110 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
---
## Step 5 — (Optional) Import existing Claude Code history
## Step 5 — (Optional) Importing existing Claude Code history
The server **automatically imports** every session under
`~/.claude/projects/` on startup, so if you've used Claude Code on this
machine before, your historical sessions, agents, events, token counts,
and cost totals should already be visible in the Sessions list.
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
To bring in history from another machine, a backup, or a `.tar.gz`
archive a teammate sent you, use **Settings → Import History** in the
UI. It supports three modes:
### Pick the right mode
```mermaid
flowchart LR
UI["Settings →<br/>Import History"] --> M1["Rescan default folder<br/>~/.claude/projects"]
UI --> M2["Scan a folder<br/>any absolute path"]
UI --> M3["Upload files<br/>.jsonl / .zip / .tar.gz / .gz"]
M1 --> P["Same parser as live<br/>hook ingestion"]
M2 --> P
M3 --> P
P --> DB[("SQLite<br/>sessions + tokens + cost")]
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
style UI fill:#a855f7,stroke:#c084fc,color:#fff
style P fill:#f59e0b,stroke:#fbbf24,color:#000
style DB fill:#10b981,stroke:#34d399,color:#fff
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
Re-imports are idempotent: sessions are deduplicated by UUID and
compaction baselines preserve pre-compaction token totals, so running
the importer twice never double-counts tokens or cost.
### Step-by-step: moving history from one machine to another
Verify it worked by opening the **Analytics** page and checking that
per-model token totals and estimated cost look correct. Full walkthrough
with per-OS archive commands in
[SETUP.md → Importing existing Claude Code history](./SETUP.md#importing-existing-claude-code-history).
### Optional: tune import limits
**On the source machine**, bundle the projects folder:
If you regularly import very large archives, these environment variables
can be raised (the defaults are generous for typical use):
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
| Variable | Default | Purpose |
| --------------------------------- | ------- | ----------------------------------------------------------- |
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Set them before `npm run dev` or `npm start`:
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks. If you regularly import very large archives, raise them
before `npm run dev` or `npm start` (also listed under
[Environment variables](#environment-variables)):
| Variable | Default | Purpose |
| ----------------------------------- | ------- | ---------------------------------------------------------- |
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
```bash
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES=17179869184 npm start # allow 16 GB extraction
```
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Production mode
@@ -328,44 +380,260 @@ and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGIN
---
## Optional: Agent extension packs
## How it works
This repository includes extension packs for both Claude Code and Codex.
AgentHub integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
- Claude Code loads project extensions from:
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/`
- `skills/` (shipped as the `agenthub` plugin's skills)
- Codex project packs live under `.codex/`:
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- Codex:
- `AGENTS.md`
- `.codex/rules/`
- `.codex/agents/`
- `.codex/skills/`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically on first run (`data/dashboard.db` in a checkout, unless `DASHBOARD_DB_PATH` says otherwise). The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `npm run setup` shows `better-sqlite3` errors
### Plugin bootstrap never comes up
This is expected and **non-fatal**. `better-sqlite3` is a native C++ module listed as an optional dependency. If prebuilt binaries are not available for your Node version or platform, npm will print gyp/compilation errors but still complete successfully.
The plugin's `SessionStart` hook only checks a fast path and hands the real
work (npm install, UI build, server start) to a detached background worker, so
a failure there does not show in the session. When the worker's last run
failed, the next session start prints a one-time **AgentHub:** message naming
the log to read. Everything is logged under `~/.claude/agenthub/runtime/`:
At runtime the server uses this fallback chain:
| File | What it holds |
|---|---|
| `bootstrap.log` | Each bootstrap step and why it stopped (Node too old, install failed, superseded build, …) |
| `npm-install.log` | npm's output for the runtime dependency install |
| `client-build.log` | The dashboard UI build (the API still works when only this fails) |
| `server.log` | The server's own output, when it was started detached |
| `last-failure.json` | The last failure the session-start message reports (removed once a bootstrap succeeds) |
1. **`better-sqlite3`** — used when prebuilt binaries are available (Node 20/22/24 on Windows x64, macOS arm64/x64, Linux x64/arm64)
2. **`node:sqlite`** — Node.js built-in SQLite module, used automatically on Node 22+ when `better-sqlite3` is unavailable
Then run `/agenthub-doctor` from Claude Code — it checks Node, the runtime
dependencies, the server and the service unit, and names the fix for each
failing row. `/agenthub-update` re-runs the bootstrap in the foreground with its
output visible. The most common causes: Node older than 22.5 (`node -v` in the
shell Claude Code was started from), no network or a proxy blocking the npm
registry (`npm-install.log`), and a port held by another program (`server.log`,
see [Port 4820 already in use](#port-4820-already-in-use)).
If you see an error box at startup saying *"SQLite backend not available"*, either:
### `better-sqlite3` errors during `npm install` / `npm run setup`
- **Upgrade to Node.js 22+** (recommended — zero native dependencies needed), or
- **Install build tools** so `better-sqlite3` can compile from source:
- **Windows:** `npm install -g windows-build-tools` or install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
Only a **checkout** install sees these — the plugin installs with
`--ignore-scripts` and never builds `better-sqlite3`. They are **harmless**:
`better-sqlite3` is an optional dependency, and when it cannot be built npm
skips it and the server uses Node's built-in `node:sqlite` (Node 22.5+). You do
**not** need Python, Visual Studio Build Tools or a C++ compiler.
Then run: `npm rebuild better-sqlite3`
To build it anyway (it is used when present), install a toolchain and run
`npm rebuild better-sqlite3`:
- **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
### "SQLite backend not available" error on startup
Neither `better-sqlite3` nor `node:sqlite` could be loaded — almost always a
Node older than 22.5. Upgrade Node.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
### `npm run dev` fails immediately
@@ -375,7 +643,7 @@ Ensure both server and client dependencies are installed:
npm run setup
```
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both root and `client/`, then re-run setup:
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both the root and `client/`, then re-run setup:
```bash
rm -rf node_modules client/node_modules
@@ -409,9 +677,66 @@ that case.
### No sessions appearing after starting Claude Code
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
**Check 1 — Is the server running?**
---
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
With the plugin, its hooks come with it — run `/agenthub-doctor` instead. For a
checkout, open `~/.claude/settings.json` and confirm it contains a `hooks`
section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
### Port 4820 already in use
When another program holds the port, the server stops with
`Port 4820 is already in use by another program…` (a running AgentHub there is
detected and simply reused). Pick another port:
```bash
DASHBOARD_PORT=4821 npm start # or: DASHBOARD_PORT=4821 npm run dev
```
The Vite dev proxy and the Claude Code hooks follow the port on their own
(`client/vite.config.ts` reads `DASHBOARD_PORT`; the server publishes the port it bound).
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
## Ports
+12 -420
View File
@@ -1,422 +1,14 @@
# Setup Guide
A comprehensive guide to setting up and configuring the Agent Dashboard, including how it integrates with Claude Code, environment variables, container deployment, and troubleshooting common issues.
## How it works
Agent Dashboard integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- `agents/*` (the `agenthub` plugin's own agents)
- Codex:
- `AGENTS.md`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically at `data/dashboard.db` on first run. The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Importing existing Claude Code history
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
### Pick the right mode
```mermaid
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
### Step-by-step: moving history from one machine to another
**On the source machine**, bundle the projects folder:
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks; see the env vars table above for
`AGENTHUB_IMPORT_MAX_BYTES`, `AGENTHUB_IMPORT_MAX_FILES`, and
`AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`.
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `better-sqlite3` errors during `npm install` / `npm run setup`
These warnings are **harmless**. `better-sqlite3` is an optional dependency — if it cannot compile, npm skips it and the server falls back to Node.js built-in `node:sqlite` (available on Node 22+).
You do **not** need Python, Visual Studio Build Tools, or any C++ compiler to run this project on Node 22+.
If you are on Node 20 or 21 and `better-sqlite3` prebuilds are not available for your platform (there is no `node:sqlite` fallback below Node 22), you have two options:
1. **Upgrade to Node.js 22+** — the built-in `node:sqlite` fallback requires no native compilation at all
2. **Install build tools** and run `npm rebuild better-sqlite3`:
- **Windows:** install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu)
### "SQLite backend not available" error on startup
This means neither `better-sqlite3` nor `node:sqlite` could be loaded. The most common cause is running Node.js < 22 without `better-sqlite3` prebuilds. Upgrade to Node.js 22+ to resolve this.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
---
### The run console won't start a run / the terminal never attaches
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
```bash
tmux -V # missing → sudo apt install tmux
curl -s http://localhost:4820/api/run/tmux # {"available":true}
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the **server's**
`PATH` differs from yours — typically a systemd `--user` service, which never
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
re-run `agenthub service uninstall && agenthub service install`. The same
applies to `claude` / `codex`: a run launches them by name.
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
---
### No sessions appearing after starting Claude Code
**Check 1 — Is the server running?**
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
Open `~/.claude/settings.json` and confirm it contains a `hooks` section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
---
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
---
### Events Today shows 0 despite recent activity
This was a known timezone bug (fixed in current version). If you are still seeing this, ensure you are running the latest code and restart the server.
---
### Port 4820 already in use
```bash
DASHBOARD_PORT=4821 npm run dev
```
Then update the Vite proxy in `client/vite.config.ts`:
```ts
proxy: {
"/api": "http://localhost:4821",
"/ws": { target: "ws://localhost:4821", ws: true }
}
```
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
This guide has moved into **[INSTALL.md](./INSTALL.md)** — the one install and
setup guide (it and this file had grown into near-duplicates). Everything that
was here is there:
| Was in SETUP.md | Now in INSTALL.md |
|---|---|
| How it works | [How it works](./INSTALL.md#how-it-works) |
| Hook auto-installation, environment variables, agent extensions, PWA | [Configuration](./INSTALL.md#configuration) |
| Database (clear, Settings page, seed) | [Database](./INSTALL.md#database) |
| Importing existing Claude Code history | [Step 5](./INSTALL.md#step-5--optional-importing-existing-claude-code-history) |
| Scripts reference, Makefile targets | [Scripts reference](./INSTALL.md#scripts-reference) |
| Troubleshooting (incl. "Port 4820 already in use") | [Troubleshooting](./INSTALL.md#troubleshooting) |
+175 -25
View File
@@ -1284,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`);
@@ -2843,18 +2845,18 @@ const COMMAND_GROUPS = [
],
[
"server",
"setup|off|status [--hostname name] [--admin name]",
"setup|install-system|tools|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]",
"list|add|passwd|role|disable|enable|provision|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",
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
@@ -3810,9 +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"],
server: ["setup", "install-system", "tools", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"],
accounts: ["list", "add", "remove", "pin", "unpin"],
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
@@ -3903,7 +3905,7 @@ async function newPassword(label) {
return first;
}
/** `agenthub server setup|off|status` — turn server mode on or off. */
/** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */
async function cmdServer(sub, flags) {
const { settingsEnv, auth } = serverAuth();
const file = settingsEnv.settingsFilePath();
@@ -3932,21 +3934,73 @@ async function cmdServer(sub, flags) {
} else {
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
}
const wasServer = auth.isServerMode();
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
envNote("AGENTHUB_MODE");
console.log(`${c.green("✔")} server mode on (${file})`);
// One Linux user per account (S3) is the default on Linux: install it now
// (one sudo), unless it is already in place or `--no-system` opts out.
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
const shared = flags["no-system"] === true || process.platform !== "linux";
if (!shared) {
if (linux.serverConf()) {
console.log(c.dim(" one Linux user per account is already installed"));
// The system unit serves; it reads the mode only when it starts.
if (!wasServer) {
const r = spawnSync("sudo", [
"-n",
"systemctl",
"restart",
"--no-block",
"agenthub.service",
]);
console.log(
r.status === 0
? `${c.green("✔")} agenthub.service restarted`
: c.yellow("! restart it: sudo systemctl restart agenthub")
);
}
} else {
console.log(" Installing one Linux user per account (asks for your sudo password)…");
try {
await installSystem({}, settingsEnv);
} catch (e) {
throw new Error(
`${e.message}\n Server mode is on, but every pane still runs as this account.` +
"\n Fix it and run `agenthub server install-system`, or keep one shared" +
" account with `agenthub server setup --no-system`."
);
}
}
// The admin made above (and anyone made before) gets their Linux account.
process.env.AGENTHUB_MODE = "server";
await cmdUsers("provision", [], { all: true });
}
console.log(" Next:");
console.log(
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
shared
? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
: " 1. Log out and back in once, so your shell joins the agenthub groups"
);
console.log(
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
);
console.log(
c.dim(" Members see only their own workspaces, but every pane still runs as this")
);
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
if (shared) {
console.log(
c.yellow(
process.platform === "linux"
? "! --no-system: every pane runs as this one Linux account"
: "! one Linux user per account needs Linux: every pane runs as this one account"
)
);
console.log(
c.dim(" Members see only their own workspaces, but an agent in a pane can read")
);
console.log(
c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).")
);
}
console.log(
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
);
@@ -3987,7 +4041,7 @@ async function cmdServer(sub, flags) {
}
console.error(
c.red(
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
)
);
process.exitCode = 1;
@@ -4008,7 +4062,7 @@ async function installSystem(flags, settingsEnv) {
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();
spawnSync("sh", ["-c", 'command -v "$1"', "sh", 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();
@@ -4112,14 +4166,25 @@ async function cmdUsers(sub, positional, flags) {
case "list":
table(
["id", "username", "role", "state"],
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
auth
.listUsers()
.map((u) => [
u.id,
u.username,
u.role,
u.disabled ? "disabled" : u.mustChangePassword ? "active (new password due)" : "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})`);
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role, {
mustChange: true,
});
console.log(
`${c.green("✔")} ${user.username} added (${user.role}) — they choose their own password at first login`
);
return;
}
case "passwd":
@@ -4232,7 +4297,18 @@ async function cmdCodexAccounts(sub, name, flags) {
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
[
"codex account",
"kind",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4241,14 +4317,70 @@ async function cmdCodexAccounts(sub, name, flags) {
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",
pinnedUsers("codex", a.name),
])
);
}
/**
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
* that user's new sessions run on that account (server/lib/account-pins.js).
*/
async function cmdAccountPin(sub, positional, tool) {
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
const [first, second] = positional;
const username = sub === "pin" ? second : first;
if (!username || (sub === "pin" && !first)) {
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
}
const user = require(path.join(REPO_ROOT, "server", "db.js"))
.db.prepare("SELECT id FROM users WHERE username = ?")
.get(username);
if (!user) throw new Error(`no user ${username}`);
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
console.log(c.dim(" Pins apply in server mode only."));
}
if (!pin.account) {
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
return;
}
const switched = await pins.applyPin(user.id, tool);
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
}
/** What `applyPin` did, for the CLI. */
const SWITCH_NOTE = {
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
kept: "Their home already holds it.",
"own-login":
"They signed in with their own login, which is left alone: no pool login is written over it.",
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
"next-session": "New sessions use it; running ones keep their account until they end.",
};
/** The `pinned` column: who an admin pinned to `account`. */
function pinnedUsers(tool, account) {
try {
return (
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
.listPins(tool)
.filter((p) => p.account === account)
.map((p) => p.username)
.join(",") || "–"
);
} catch {
return "–";
}
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional, flags = {}) {
serverAuth(); // the same settings + database the server reads
@@ -4260,6 +4392,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex");
}
if (sub === "pin" || sub === "unpin")
return cmdAccountPin(sub, positional, flags.tool || "claude");
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") {
console.log(
@@ -4281,7 +4415,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table(
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
[
"account",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota 5h",
"quota wk",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4289,6 +4434,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
a.tokens5h,
a.tokens7d,
pct(a.quota?.fiveHour),
pct(a.quota?.sevenDay),
a.expired
@@ -4298,6 +4445,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
: a.quota?.error
? `no quota (${a.quota.error})`
: "ok",
pinnedUsers("claude", a.name),
])
);
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
@@ -4305,7 +4453,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
return;
}
console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
c.red(
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
)
);
process.exitCode = 1;
}
+1 -1
View File
@@ -52,7 +52,7 @@ 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. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
- **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`, `#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.
+5 -1
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" />
+38 -68
View File
@@ -29,7 +29,7 @@
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.2",
"react-router-dom": "^7.18.4",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
@@ -1204,9 +1204,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1254,15 +1251,6 @@
"node": ">= 8"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.4",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1362,9 +1350,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1379,9 +1364,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1396,9 +1378,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1413,9 +1392,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1430,9 +1406,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1447,9 +1420,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1464,9 +1434,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1481,9 +1448,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1498,9 +1462,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1515,9 +1476,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1532,9 +1490,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1549,9 +1504,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1566,9 +1518,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2782,6 +2731,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
@@ -5514,35 +5475,39 @@
}
},
"node_modules/react-router": {
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
"license": "MIT",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"dependencies": {
"@remix-run/router": "1.23.4"
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=16.8"
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
"license": "MIT",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"dependencies": {
"@remix-run/router": "1.23.4",
"react-router": "6.30.6"
"react-router": "7.18.4"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/read-cache": {
@@ -5808,6 +5773,11 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+1 -1
View File
@@ -32,7 +32,7 @@
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.2",
"react-router-dom": "^7.18.4",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
+33 -3
View File
@@ -64,6 +64,7 @@ import { GlobalTooltip } from "./components/Tooltip";
import { Toaster } from "./components/Toaster";
import { ConfirmHost } from "./components/ConfirmHost";
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
import { usePermissions } from "./components/AuthGate";
import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
@@ -87,7 +88,11 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
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();
@@ -107,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();
@@ -211,13 +221,33 @@ export default function App() {
}
/>
<Route
path="users"
path="artifacts"
element={
<Page>
<Users />
<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={
+64
View File
@@ -0,0 +1,64 @@
/**
* @file AuthCard.tsx
* @description The card the signed-out screens share (the login page, the
* first-login password change in `FirstPasswordPage.tsx`): a centred form
* with the theme's accent glow and grid behind the brand logo, a title and a
* subtitle. While it shows, the OS's light/dark applies (`applySystemTheme`),
* since no account's theme has been chosen for it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useLayoutEffect, type FormEvent, type ReactNode } from "react";
import { BRAND_LOGO } from "../lib/brand";
import { applySystemTheme } from "../hooks/useTheme";
export function AuthCard({
title,
subtitle,
onSubmit,
children,
}: {
title: string;
subtitle: string;
onSubmit: (e: FormEvent) => void;
children: ReactNode;
}) {
// Follow the OS, live.
useLayoutEffect(() => {
applySystemTheme();
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
mq?.addEventListener?.("change", applySystemTheme);
return () => mq?.removeEventListener?.("change", applySystemTheme);
}, []);
return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form
onSubmit={onSubmit}
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">{title}</h1>
<p className="mt-2 text-sm text-fg-secondary">{subtitle}</p>
</div>
{children}
</div>
</form>
</main>
);
}
+94 -91
View File
@@ -6,33 +6,44 @@
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
* someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
* 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`).
* The login page follows the OS's light/dark (`AuthCard`). An account whose
* password an admin gave (`mustChangePassword`) sees only
* `FirstPasswordPage` until it picks its own.
* @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 { AuthCard } from "./AuthCard";
import { FirstPasswordPage } from "./FirstPasswordPage";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import { hydrate } from "../lib/userPrefs";
type AuthValue = { state: AuthState; logout: () => Promise<void> };
type AuthValue = {
state: AuthState;
logout: () => Promise<void>;
updateUser: (patch: Partial<AuthUser>) => void;
};
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
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);
@@ -53,6 +64,10 @@ export function usePermissions() {
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) =>
@@ -134,6 +149,15 @@ export function AuthGate({ children }: { children: ReactNode }) {
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 })} />;
@@ -142,7 +166,20 @@ export function AuthGate({ children }: { children: ReactNode }) {
await api.auth.logout().catch(() => {});
setState({ mode: "server", user: null });
};
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
// A password an admin gave: nothing else until the user picks their own
// (the server refuses the rest anyway — EPWCHANGE).
if (state.mode === "server" && state.user?.mustChangePassword) {
return (
<FirstPasswordPage
user={state.user}
onDone={() => updateUser({ mustChangePassword: false })}
onLogout={() => void logout()}
/>
);
}
return (
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
);
}
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
@@ -153,14 +190,6 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
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);
@@ -176,86 +205,60 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
}
};
// Nobody is signed in, so no account's theme applies: AuthCard follows the OS.
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%)",
}}
<AuthCard title={t("auth.title")} subtitle={t("auth.subtitle")} onSubmit={submit}>
<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)}
/>
<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. */}
</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="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}
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"
>
{busy ? t("auth.submitting") : t("auth.submit")}
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
</div>
</form>
</main>
</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>
</AuthCard>
);
}
+4
View File
@@ -165,12 +165,15 @@ export function MenuItem({
danger,
disabled,
testId,
title,
children,
}: {
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
testId?: string;
/** Hover text — e.g. why a disabled item cannot run. */
title?: string;
children: ReactNode;
}) {
return (
@@ -179,6 +182,7 @@ export function MenuItem({
role="menuitem"
data-testid={testId}
disabled={disabled}
title={title}
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
onClick={onSelect}
>
+110
View File
@@ -0,0 +1,110 @@
/**
* @file FirstPasswordPage.tsx
* @description The screen an account signed in with a password an admin GAVE
* (created or reset on the Users page — `mustChangePassword`) sees instead of
* the app: the current password, a new one that must pass every rule (the
* `PasswordRules` checklist ticks them as they are typed) and the same again,
* sent to `POST /api/me/password`. On success the app opens; "Sign out"
* leaves. The server refuses every other request meanwhile (EPWCHANGE).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, type AuthUser } from "../lib/api";
import { passwordProblems } from "../lib/passwordRules";
import { AuthCard } from "./AuthCard";
import { PasswordRules } from "./users/PasswordRules";
export function FirstPasswordPage({
user,
onDone,
onLogout,
}: {
user: AuthUser;
onDone: () => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [again, setAgain] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const mismatch = again.length > 0 && again !== next;
const strong = passwordProblems(next, user.username).length === 0;
const ready = current.length > 0 && strong && again === next && !busy;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.me.changePassword(current, next);
onDone();
} catch (err) {
const code = (err as { code?: string }).code;
setError(
code && t(`users:errors.${code}`, { defaultValue: "" })
? t(`users:errors.${code}`)
: (err as Error).message
);
} finally {
setBusy(false);
}
};
const field = (
label: string,
value: string,
set: (v: string) => void,
autoComplete: string,
autoFocus = false
) => (
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
<span>{label}</span>
<input
type="password"
className="input h-11 w-full px-4"
autoComplete={autoComplete}
autoFocus={autoFocus}
required
value={value}
onChange={(e) => set(e.target.value)}
/>
</label>
);
return (
<AuthCard
title={t("auth.mustChangeTitle")}
subtitle={t("auth.mustChangeSubtitle")}
onSubmit={submit}
>
{field(t("auth.currentPassword"), current, setCurrent, "current-password", true)}
{field(t("auth.newPassword"), next, setNext, "new-password")}
<PasswordRules password={next} username={user.username} />
{field(t("auth.confirmPassword"), again, setAgain, "new-password")}
{(mismatch || error) && (
<p role="alert" className="text-sm text-status-danger">
{error ?? t("auth.mismatch")}
</p>
)}
<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={!ready}
>
{busy ? t("auth.saving") : t("auth.mustChangeSubmit")}
</button>
<button
type="button"
className="block w-full text-center text-sm text-fg-secondary hover:text-fg-primary"
onClick={onLogout}
>
{t("auth.logout")}
</button>
</AuthCard>
);
}
+10 -8
View File
@@ -4,12 +4,14 @@
* 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.
* layer, under anything stacked above it. `useDialog` takes the focus while it
* is open and gives it back, and its Escape closes only this, the top dialog.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useRef } from "react";
import { createPortal } from "react-dom";
import { useDialog } from "../hooks/useDialog";
export function ImageLightbox({
src,
@@ -20,18 +22,18 @@ export function ImageLightbox({
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const panel = useRef<HTMLDivElement>(null);
useDialog(true, panel, onClose);
return createPortal(
<div
ref={panel}
tabIndex={-1}
role="dialog"
aria-modal="true"
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"
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
>
<img
src={src}
+4 -7
View File
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { WSMessage, ImportProgressMessage } from "../lib/types";
import { PathPickButton } from "./PathPickButton";
import { copyText } from "../lib/clipboard";
type Mode = "rescan" | "path" | "upload" | "backup";
@@ -247,13 +248,9 @@ export function ImportHistory() {
const copyArchiveCmd = async () => {
if (!guide) return;
try {
await navigator.clipboard.writeText(guide.archive_command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
if (!(await copyText(guide.archive_command, true))) return;
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const progressText = (() => {
+14 -3
View File
@@ -76,7 +76,7 @@
*
* ----------------------------------------------------------------------------- */
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
import { useOnboarding } from "../lib/userPrefs";
import { OnboardingTour } from "./onboarding/OnboardingTour";
// Loaded only for an account that has not finished onboarding: the wizard
// carries the appearance, pet and notification settings screens.
const OnboardingWizard = lazy(() =>
import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard }))
);
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const onboarding = useOnboarding();
const [updateAvailable, setUpdateAvailable] = useState(false);
const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
<UpdateNotifier />
<InterruptedSessionsPrompt />
<SagiMount />
<OnboardingWizard />
{onboarding.loaded && !onboarding.onboardedAt && (
<Suspense fallback={null}>
<OnboardingWizard />
</Suspense>
)}
<OnboardingTour />
{drawer && drawerOpen && (
<div
+1 -5
View File
@@ -12,7 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type Ref } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
@@ -23,15 +23,12 @@ export function PathPickButton({
start,
onPick,
showLabel = false,
buttonRef,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
/** Lets a field beside it open the same picker (a click on an empty path). */
buttonRef?: Ref<HTMLButtonElement>;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
@@ -55,7 +52,6 @@ export function PathPickButton({
return (
<>
<button
ref={buttonRef}
type="button"
onClick={pick}
disabled={busy}
+2 -1
View File
@@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope";
import { PathPickButton } from "./PathPickButton";
import { copyText } from "../lib/clipboard";
/** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
@@ -863,7 +864,7 @@ export function RemoteSources() {
: t("remoteSources.enable", "Enable")}
</MenuItem>
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
<MenuItem onSelect={pick((s) => void copyText(s.host))}>
{t("rowMenu.copyHost")}
</MenuItem>
<MenuSeparator />
+595
View File
@@ -0,0 +1,595 @@
/**
* @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,
stepRunMenu,
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({ safe: true }) } : {}),
});
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"));
}}
onStep={(dir) => {
if (!stepRunMenu(chat.runId, dir)) 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>
);
}
+21 -6
View File
@@ -8,7 +8,9 @@
* 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, and a v2 pet at idle holds the gaze pose `gaze` names. An
* 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. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls
@@ -18,7 +20,7 @@
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 COLS = 8;
@@ -58,12 +60,18 @@ 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 };
/** 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,
@@ -73,6 +81,7 @@ 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;
@@ -80,8 +89,8 @@ export function PetSprite({
onBroken: () => void;
}) {
const url = api.pets.spriteUrl(id);
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
const sleeping = !action && mood === "sleeping";
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 });
@@ -95,13 +104,17 @@ export function PetSprite({
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.
// `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(() => {
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();
@@ -132,6 +145,7 @@ export function PetSprite({
src={url}
alt=""
data-mood={mood}
data-moment={moment ?? undefined}
className="sagi-pet-image"
width={size}
height={size}
@@ -141,7 +155,8 @@ export function PetSprite({
/>
);
}
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
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 }
+213 -62
View File
@@ -5,17 +5,20 @@
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
* tick, WS reducer) runs only while it is on. Persists across routes and
* shares the single WebSocket.
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI.
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
* bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path: an unmatched Ask query starts a claude session in
* the "Sagi" workspace carrying it as the first prompt, then opens that
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* The "do the job" path: an unmatched question is the chat's — it starts a
* session of the default AI tool in the "Sagi" workspace; a default tool the
* chat cannot read back starts there and its session view opens
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -39,7 +42,7 @@
* ## Internal dependencies
* - `./SagiAvatar`
* - `./SpeechBubble`
* - `./SagiPanel`
* - `./SagiInfo`
* - `./useSagiBrain`
* - `./useSagiPosition`
* - `./intents`
@@ -78,18 +81,31 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble";
import { SagiPanel } from "./SagiPanel";
import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount";
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
@@ -115,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" });
@@ -159,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));
@@ -171,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();
@@ -209,7 +226,10 @@ export function Sagi() {
function SagiBody() {
const { t } = useTranslation("settings");
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);
@@ -226,11 +246,32 @@ function SagiBody() {
() =>
sagiPrefs.subscribe(() => {
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(() => {
@@ -281,27 +322,48 @@ function SagiBody() {
[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);
@@ -310,11 +372,34 @@ function SagiBody() {
brain.setThinking(false);
}
})();
return null;
return { handled: true };
},
[brain, navigate, t]
[brain, navigate, t, name]
);
// 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,
top: place.top,
@@ -326,18 +411,51 @@ function SagiBody() {
return (
<>
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
does not - it is Sagi talking, and it has to stay by its mouth. */}
{!place.dragging && open && (
<SagiFlyout anchor={anchor}>
<SagiPanel
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>
)}
@@ -358,7 +476,11 @@ function SagiBody() {
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
<SpeechBubble
bubble={brain.bubble}
onDismiss={brain.dismissBubble}
onOpen={openSession}
/>
</SagiFlyout>
)}
@@ -370,7 +492,10 @@ function SagiBody() {
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={() => {
@@ -385,19 +510,27 @@ function SagiBody() {
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}
@@ -405,10 +538,28 @@ function SagiBody() {
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>
</>
+5 -1
View File
@@ -37,12 +37,14 @@
* ============================================================================= */
import { memo, useEffect, useRef } from "react";
import type { Mood } from "./brain";
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
@@ -88,6 +90,7 @@ export const SagiAvatar = memo(function SagiAvatar({
mood,
reducedMotion,
size = 68,
moment = null,
}: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>();
@@ -148,6 +151,7 @@ export const SagiAvatar = memo(function SagiAvatar({
ref={rootRef}
className="sagi-avatar"
data-mood={mood}
data-moment={moment ?? undefined}
data-reduced={reducedMotion ? "1" : "0"}
width={size}
height={size}
+298
View File
@@ -0,0 +1,298 @@
/**
* @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";
import { isSubmitEnter } from "../../lib/keys";
/** 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 (isSubmitEnter(e)) {
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>
);
}
-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([]);
});
});
@@ -261,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");
});
@@ -284,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);
});
@@ -313,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" }));
@@ -329,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);
+235 -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,127 +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(() => {
// 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") setTick(Date.now());
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(() => {
@@ -192,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,
+68 -22
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 (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) 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>
*/
/* =============================================================================
@@ -56,6 +56,8 @@ import { useDialog } from "../hooks/useDialog";
import {
LayoutDashboard,
FolderOpen,
HardDrive,
Gem,
BarChart3,
Settings,
Wifi,
@@ -71,12 +73,13 @@ import {
ChevronDown,
FolderTree,
LogOut,
UsersRound,
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";
@@ -88,6 +91,10 @@ 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;
const STORAGE_KEY = "sidebar-collapsed";
@@ -194,7 +201,8 @@ export function Sidebar({
location.pathname.startsWith("/settings") ||
new URLSearchParams(location.search).has("settings");
// Checking for (and applying) an update acts on the host: an admin's.
const canUpdate = usePermissions().isAdmin;
const perms = usePermissions();
const canUpdate = perms.isAdmin;
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -469,12 +477,13 @@ export function Sidebar({
<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,
// Server mode: the Users page, for an admin or analyst.
// 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: "/users", icon: UsersRound, key: "nav:users" }]
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
: []),
].map(({ to, icon: Icon, key }) => {
const label = t(key);
@@ -532,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
@@ -547,22 +609,6 @@ export function Sidebar({
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
{auth.state.mode === "server" && auth.state.user && (
<button
type="button"
onClick={() => void auth.logout()}
title={`${t("common:auth.signedInAs", {
name: auth.state.user.username,
role: auth.state.user.role,
})} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onToggle}
+2 -1
View File
@@ -14,6 +14,7 @@ import { api } from "../lib/api";
import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { CopyButton } from "./event-views/primitives";
import { copyText } from "../lib/clipboard";
export function snapshotUrl(token: string): string {
return `${window.location.origin}/snapshot/${token}`;
@@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) {
try {
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
setToken(snapshot.token);
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
void copyText(snapshotUrl(snapshot.token));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
+56 -40
View File
@@ -9,8 +9,12 @@
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
* workspace or session, then at most every 30 s while events or session
* updates arrive (spend only moves with work) and every 2 min otherwise, while
* the tab is visible. The session counts re-read only when a session is
* created or its status / waiting / archived state changes (every 60 s
* otherwise). Each figure carries its
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
/** Spend moves only with work: at most this often while events arrive… */
const COST_POLL_MS = 30_000;
/** …and this often regardless (date rollover, budget period, a missed frame). */
const COST_FALLBACK_MS = 120_000;
/** The counts' safety poll; session frames that change a count drive the rest. */
const COUNTS_FALLBACK_MS = 60_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
@@ -384,51 +394,57 @@ export function StatusBar({
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
// Only a frame that moves a session between the counted states (a new
// session, a status / waiting / archived change) re-reads: a session row is
// re-broadcast on almost every hook.
const [countsChanged] = useState(() => sessionChangeMatcher());
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
// Bumped on every lane/session switch: an answer for the previous one is dropped.
const costGen = useRef(0);
const loadCosts = useCallback(() => {
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live() && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live() && setSessionCost(r))
.catch(() => undefined);
}
}, [lane, sessionId]);
useEffect(() => {
let live = true;
costGen.current++;
// Another workspace or session: never show the previous one's figure.
setLaneCost(null);
setSessionCost(null);
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
if (document.visibilityState === "visible") loadCosts();
return () => {
live = false;
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
costGen.current++;
};
}, [lane, sessionId]);
}, [loadCosts]);
useLiveRefresh(loadCosts, {
intervalMs: COST_FALLBACK_MS,
throttleMs: COST_POLL_MS,
match: (msg) =>
msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created",
});
return (
<footer
+6 -3
View File
@@ -33,13 +33,16 @@ export function Switch({
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
// Hover darkens the track (enabled only), so it reads as clickable.
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
checked
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
}`}
>
<span
aria-hidden
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
checked ? "translate-x-[18px]" : "translate-x-0.5"
}`}
/>
+92 -15
View File
@@ -16,9 +16,13 @@
* — 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.
@@ -81,7 +85,18 @@
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";
@@ -89,6 +104,7 @@ 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";
@@ -107,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;
@@ -130,7 +172,7 @@ export function UpdateNotifier() {
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);
@@ -139,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);
@@ -200,7 +244,7 @@ export function UpdateNotifier() {
dismissedSha !== dismissKey &&
closedKey !== dismissKey
);
const busy = applying || restarting;
const busy = applying || restarting || fixing;
const closeForNow = useCallback(() => {
if (busy || !dismissKey) return;
@@ -317,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";
@@ -493,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"
@@ -514,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 ? (
+17 -10
View File
@@ -10,7 +10,8 @@
* 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 (`owner_name`; yours
* 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.
@@ -48,6 +49,7 @@ 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 { 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.
@@ -60,6 +62,9 @@ const DestructiveLaneModal = lazy(() =>
/** 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);
@@ -150,7 +155,7 @@ export function WorkspaceTree() {
// 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) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
);
}, [lanes, order, toolFilter, grouped, meId]);
const q = query.trim().toLowerCase();
@@ -338,8 +343,8 @@ export function WorkspaceTree() {
lane.owner_id == null
? t("tree.noOwner")
: lane.owner_id === meId
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
: (lane.owner_name ?? `#${lane.owner_id}`);
? 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 (
@@ -362,12 +367,14 @@ export function WorkspaceTree() {
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<span
aria-hidden
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{lane.owner_name?.[0] ?? "?"}
</span>
<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}
@@ -6,7 +6,9 @@
* `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.
* OS's light/dark; a sign-in swaps in the account's saved appearance. A
* password an admin gave shows only the "choose your own password" page,
* whose button waits for every rule, until the change succeeds.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +20,7 @@ import "../../i18n";
const me = vi.fn();
const login = vi.fn();
const prefs = vi.fn();
const changePassword = vi.fn();
const LANES = [
{ id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 },
@@ -27,7 +30,11 @@ vi.mock("../../lib/api", () => ({
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: {} }) },
me: {
prefs: () => prefs(),
savePrefs: () => Promise.resolve({ prefs: {} }),
changePassword: (c: string, n: string) => changePassword(c, n),
},
},
}));
@@ -56,10 +63,30 @@ beforeEach(() => {
me.mockReset();
login.mockReset();
prefs.mockReset().mockResolvedValue({ prefs: {} });
changePassword.mockReset().mockResolvedValue({ ok: true });
localStorage.clear();
});
describe("AuthGate", () => {
it("makes an account with an admin-given password choose its own first", async () => {
const ue = userEvent.setup();
me.mockResolvedValue({ mode: "server", user: { ...user, mustChangePassword: true } });
render(<AuthGate>app</AuthGate>);
const save = await screen.findByRole("button", { name: "Save and continue" });
expect(screen.queryByText("app")).toBeNull();
await ue.type(screen.getByLabelText("Current password"), "123");
await ue.type(screen.getByLabelText("New password"), "weakpass");
await ue.type(screen.getByLabelText("Confirm new password"), "weakpass");
expect(save).toBeDisabled();
await ue.clear(screen.getByLabelText("New password"));
await ue.clear(screen.getByLabelText("Confirm new password"));
await ue.type(screen.getByLabelText("New password"), "Green-Tea-42");
await ue.type(screen.getByLabelText("Confirm new password"), "Green-Tea-42");
await ue.click(save);
expect(changePassword).toHaveBeenCalledWith("123", "Green-Tea-42");
expect(await screen.findByText("app")).toBeInTheDocument();
});
it("renders the app in local mode", async () => {
me.mockResolvedValue({ mode: "local" });
render(<AuthGate>app</AuthGate>);
@@ -58,14 +58,25 @@ describe("MarkdownPreview", () => {
describe("FileViewer — markdown preview", () => {
const data = { text: "# Hello\n", truncated: false } as never;
it("switches a markdown file between raw and preview", async () => {
it("opens a markdown file rendered, and switches it to raw and back", async () => {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
expect(screen.queryByTestId("markdown-preview")).toBeNull();
});
it("opens it as source when Settings → Appearance says Raw", () => {
window.localStorage.setItem("agenthub.markdown.view", "raw");
try {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument();
} finally {
window.localStorage.removeItem("agenthub.markdown.view");
}
});
it("offers no preview for another file type", () => {
@@ -3,7 +3,7 @@
* @description Unit tests for the Browse button beside path fields: a chosen
* 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).
* EREMOTE included), where a double-click on a file picks it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -15,7 +15,7 @@ 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,7 +55,7 @@ describe("PathPickButton", () => {
expect(toast.error).not.toHaveBeenCalled();
});
it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
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({
@@ -68,9 +68,9 @@ describe("PathPickButton", () => {
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);
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
expect(within(dialog).queryByText("Select this folder")).toBeNull();
await userEvent.click(await within(dialog).findByText("id_ed25519"));
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();
@@ -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", () => {
@@ -238,7 +238,7 @@ describe("StatusBar", () => {
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");
expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles");
expect(personal).not.toHaveTextContent("in use");
expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired");
@@ -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");
});
});
@@ -1,7 +1,8 @@
/**
* @file WorkspaceTree.owners.test.tsx
* @description Server mode, the sidebar workspace list for an analyst: every
* user's workspaces, grouped under their owner (yours first, no owner last),
* 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>
@@ -30,7 +31,11 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
}) as unknown as Lane;
const lanes = [
lane(1, 3, "bob", "Bob app"),
{
...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"),
@@ -61,6 +66,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
},
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: () => "" },
},
};
@@ -113,6 +123,23 @@ describe("WorkspaceTree for an analyst", () => {
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();
@@ -154,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,
});
@@ -175,11 +180,21 @@ 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, limit: 6 });
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();
});
@@ -197,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();
@@ -207,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();
@@ -224,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 () => {
+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,952 @@
/**
* @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).
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
* @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 { X } from "lucide-react";
import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "input w-full";
const label = "block text-xs text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
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>
);
}
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
const clean = text.replace(/^\uFEFF/, "");
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
return {
name: frontmatterField(clean, "name"),
description: frontmatterField(clean, "description"),
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
};
}
/** The name field with the folder/command it becomes. */
export 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-1`}
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-1`}
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-1`}
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"
}`}
>
<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()}
>
{t("skill.pickFiles")}
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => folderInput.current?.click()}
>
{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 gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
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-1`}
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-1 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-1`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
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="select w-full"
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">
<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-1 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">{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 gap-2 pt-1">
<button
type="button"
className="btn-ghost text-sm"
onClick={onClose}
disabled={busy}
>
{t("form.cancel")}
</button>
<button
type="button"
className="btn-primary text-sm"
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 gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
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-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{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}
>
{t("file.upload")}
</button>
</div>
</form>
);
}
+4 -2
View File
@@ -4,7 +4,8 @@
* already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
* (`MarkdownPreview`); Raw is the default.
* (`MarkdownPreview`), opening as Settings → Appearance says
* (`lib/markdownView.ts`: Preview by default).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -15,6 +16,7 @@ import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { readMarkdownPreview } from "../../lib/markdownView";
export function FileViewer({
state,
@@ -24,7 +26,7 @@ export function FileViewer({
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false);
const [preview, setPreview] = useState(readMarkdownPreview);
const canPreview = isMarkdown(state.path) && !!state.data;
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
@@ -0,0 +1,276 @@
/**
* @file AgentDetails.tsx
* @description What the chat shows of a subagent, like Claude Desktop: an
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
* the task it was given and the report it returned — read off the call, its
* result (`tool_result.agent`) and, for a background agent, the
* `<task-notification>` that closed it — and, one click further, the
* subagent's own conversation as a small chat right there in the step, every
* step of it. The chat's owner (ConversationView) supplies the links through
* `AgentContext`: which agent transcript a call launched, the notifications
* seen, and how to render one agent's conversation (a nested
* ConversationView — passed in, so this module never imports it back).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { createContext, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
import { fmt, formatModelName, formatMs } from "../../lib/format";
import { MarkdownContent } from "./MarkdownContent";
export type AgentStatus = "running" | "done" | "error" | "stopped";
/** One subagent as the chat knows it from the call that launched it. */
export interface AgentRef {
/** The Agent/Task `tool_use` id. */
toolUseId: string;
/** Its own transcript's `agent_id`; null while nothing names it yet. */
agentId: string | null;
type: string;
description: string;
model: string | null;
status: AgentStatus;
background: boolean;
prompt: string | null;
report: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
/** What a `<task-notification>` said about a background agent. */
export interface AgentNotice {
status: string;
result: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
export interface AgentLinks {
/** tool_use id → agent transcript id, from the session's transcript list. */
ids: Map<string, string>;
/** tool_use id → the notification that closed that background agent. */
notices: Map<string, AgentNotice>;
/** The session runs in a pane: a call with no result yet still works. */
live: boolean;
/** One agent's own conversation, as a chat that fills its box. */
render?: (agent: AgentRef) => React.ReactNode;
}
export const AgentContext = createContext<AgentLinks>({
ids: new Map(),
notices: new Map(),
live: false,
});
const AGENT_TOOLS = new Set(["Agent", "Task"]);
export const isAgentCall = (b: TranscriptContent) =>
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
// The result names the agent's own transcript (`agent-<id>.jsonl`).
const AGENT_ID = /agentId:\s*([\w-]+)/;
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
const tag = (text: string, name: string) =>
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
/** The time, tokens and tool calls of a `<usage>` block, in either form
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
const body = text.match(USAGE)?.[1] ?? "";
const num = (...names: string[]) => {
for (const n of names) {
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
if (v) return Number(v);
}
return null;
};
return {
durationMs: num("duration_ms"),
tokens: num("subagent_tokens", "total_tokens"),
toolUses: num("tool_uses"),
};
}
/** Each `<task-notification>` in the transcript, by the call it closes. */
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
const out = new Map<string, AgentNotice>();
for (const msg of messages) {
if (msg.type !== "user") continue;
for (const c of msg.content) {
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
for (const part of c.text.split("<task-notification>").slice(1)) {
const id = tag(part, "tool-use-id");
if (!id) continue;
out.set(id, {
status: tag(part, "status") ?? "completed",
result: tag(part, "result"),
...parseUsage(part),
});
}
}
}
return out;
}
/** A report without the footer Claude Code appends for the orchestrator. */
function stripFooter(text: string): string {
return text
.replace(USAGE, "")
.replace(/^agentId:.*$/m, "")
.trim();
}
/** Everything the chat can say about the subagent `block` launched. */
export function agentRef(
block: TranscriptContent,
result: TranscriptContent | null | undefined,
links: AgentLinks
): AgentRef {
const raw = block.input as Record<string, unknown> | undefined;
const input = raw && !("_truncated" in raw) ? raw : {};
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
const id = block.id ?? "";
const facts = result?.agent;
const output = result?.output ?? "";
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
const notice = background ? links.notices.get(id) : undefined;
const usage = !background && output ? parseUsage(output) : null;
let status: AgentStatus;
if (result?.is_error) status = "error";
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
else if (notice && notice.status !== "completed")
status = notice.status === "failed" ? "error" : "stopped";
else status = "done";
return {
toolUseId: id,
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
type: str(input.subagent_type) ?? "Agent",
description: str(input.description) ?? "",
model: facts?.model ?? str(input.model),
status,
background,
prompt: str(input.prompt),
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
};
}
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
};
/** One muted line: its status as an icon, then type · model · time ·
* tokens · tool calls, as far as they are known. */
export function AgentFacts({ agent }: { agent: AgentRef }) {
const { t } = useTranslation("sessions");
const parts = [
agent.type,
agent.model && formatModelName(agent.model),
agent.background && t("conversation.agent.background"),
agent.durationMs != null && formatMs(agent.durationMs),
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
].filter(Boolean);
return (
<div
data-testid="agent-facts"
title={t(`conversation.agent.status.${agent.status}`)}
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
>
{STATUS_ICON[agent.status]}
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
<span className="truncate">{parts.join(" · ")}</span>
</div>
);
}
const linkClass =
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
/** The subagent's own conversation, a thread hung under its step by a line
* on the left — as tall as it is, up to 24rem, then it scrolls. */
export function AgentChatBox({ agent }: { agent: AgentRef }) {
const links = useContext(AgentContext);
return (
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
{links.render?.(agent)}
</div>
);
}
/** An Agent/Task step opened in the chat: one line of facts, its report as
* prose, and "View conversation ›", which puts the agent's own thread (its
* task, every step, its reply) in the report's place. With no transcript
* to show, the task it was given opens on its own instead. */
export function AgentStepBody({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const links = useContext(AgentContext);
const agent = agentRef(block, result, links);
const canOpen = !!agent.agentId && !!links.render;
const [thread, setThread] = useState(false);
const [task, setTask] = useState(false);
return (
<div data-testid="agent-step" className="space-y-1.5">
<AgentFacts agent={agent} />
{thread ? (
<AgentChatBox agent={agent} />
) : (
<>
{!canOpen && agent.prompt && task && (
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
<MarkdownContent text={agent.prompt} dense />
</div>
)}
{agent.report && (
<div
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
>
<MarkdownContent text={agent.report} dense />
</div>
)}
</>
)}
{canOpen ? (
<button
type="button"
data-testid="agent-open"
aria-expanded={thread}
onClick={() => setThread(!thread)}
className={linkClass}
>
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
</button>
) : (
agent.prompt && (
<button
type="button"
aria-expanded={task}
onClick={() => setTask(!task)}
className={linkClass}
>
{t("conversation.agent.prompt")}
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
</button>
)
)}
</div>
);
}
@@ -14,6 +14,12 @@
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once. The main
* transcript hands MessageList its `sessionId`, so the list can read its
* background agents' own transcripts for their progress.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -61,13 +67,16 @@
*
* ----------------------------------------------------------------------------- */
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, promptTokens } from "./MessageList";
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -77,9 +86,19 @@ 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;
// The server saw this session's transcript grow (`transcript_changed`, itself
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
const PUSH_FETCH_MS = 300;
interface ConversationViewProps {
sessionId: string;
@@ -87,8 +106,15 @@ interface ConversationViewProps {
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
/** A subagent's conversation inside its step: no toolbar, as tall as its
* content up to 24rem, then it scrolls. */
compact?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** Opens an artifact card's file in the chat's artifact panel. */
onOpenArtifact?: (path: string) => void;
/** The messages on screen (for an artifact's versions), as they change. */
onMessages?: (messages: TranscriptMessage[]) => 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. */
@@ -104,13 +130,18 @@ interface ConversationViewProps {
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
/** The session has ended (and no pane holds it): poll slowly, if at all. */
ended?: boolean;
}
export function ConversationView({
function ConversationViewPane({
sessionId,
initialTranscriptId,
fill = false,
compact = false,
onOpenFile,
onOpenArtifact,
onMessages,
live = false,
follow = 0,
waiting = false,
@@ -118,22 +149,25 @@ export function ConversationView({
stopped = 0,
onContext,
onNotStarted,
ended = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const shown = messages;
// 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(() => onMessages?.(shown), [shown, onMessages]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
for (let i = shown.length - 1; i >= 0; i--) {
const m = shown[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
}, [shown, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -161,12 +195,38 @@ export function ConversationView({
// 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.
// A subagent's own conversation opens inside its step as a small chat: this
// same view, on that agent's transcript.
const renderAgent = useCallback(
(a: AgentRef) => (
<ConversationView
key={a.agentId}
sessionId={sessionId}
initialTranscriptId={a.agentId}
compact
live={live && a.status === "running"}
ended={a.status !== "running"}
/>
),
[sessionId, live]
);
const agentLinks = useMemo<AgentLinks>(() => {
const ids = new Map<string, string>();
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
return { ids, notices: agentNotices(shown), live, render: renderAgent };
}, [transcripts, shown, live, renderAgent]);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
async function loadTranscripts() {
try {
@@ -178,14 +238,18 @@ export function ConversationView({
}
}
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(() => {
@@ -202,6 +266,12 @@ export function ConversationView({
firstLineRef.current = 0;
async function load() {
// No session yet: no history.
if (!sessionId) {
setMessages([]);
setLoading(false);
return;
}
try {
setError(null);
setLoading(true);
@@ -245,6 +315,7 @@ export function ConversationView({
// parsed message we've seen. The server already de-overlaps via
// afterLine, so we can safely append.
const fetchNewMessages = useCallback(async () => {
if (!sessionId) return;
if (fetchingRef.current) {
// Coalesce: remember a trigger arrived during this fetch and re-run
// exactly once when the in-flight request settles.
@@ -299,24 +370,39 @@ export function ConversationView({
// 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]
);
const fetchPushed = useMemo(
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab: the visibility handler below catches up once on show.
if (document.visibilityState !== "visible") return;
// 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;
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchNewMessages();
if (msg.type === "transcript_changed") fetchPushed();
else fetchSoon();
});
return unsubscribe;
}, [sessionId, fetchNewMessages]);
}, [sessionId, fetchSoon, fetchPushed, 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
@@ -336,9 +422,12 @@ export function ConversationView({
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) {
@@ -363,7 +452,7 @@ export function ConversationView({
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.
@@ -471,7 +560,7 @@ export function ConversationView({
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
if (!loading && shown.length > 0) {
scrollToBottom();
}
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -480,7 +569,7 @@ export function ConversationView({
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
{!loading && !fill && (
{!loading && !fill && !compact && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && (
<div className="relative">
@@ -528,7 +617,13 @@ export function ConversationView({
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
style={
compact
? { maxHeight: "24rem" }
: fill
? { minHeight: 0 }
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
}
>
{/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
@@ -551,10 +646,12 @@ export function ConversationView({
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
<div
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
>
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
) : shown.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">
@@ -570,7 +667,7 @@ export function ConversationView({
</button>
)}
</div>
) : messages.length === 0 ? (
) : shown.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">
@@ -579,13 +676,19 @@ export function ConversationView({
</div>
) : (
<MessageList
messages={messages}
messages={shown}
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
agentLinks={agentLinks}
compact={compact}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
/>
)}
</div>
@@ -612,7 +715,7 @@ export function ConversationView({
)}
{/* New messages indicator */}
{showNewMsg && !fill && (
{showNewMsg && !fill && !compact && (
<button
onClick={() => {
scrollToBottom();
@@ -627,3 +730,7 @@ export function ConversationView({
</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,9 +4,11 @@
* 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. An absolute file path (bare or in inline code) opens
* through `OpenFileContext` when the page provides one; `linkify` does the
* same links for plain text. To make a reply easy to scan, bold is coloured
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
* inline code or as a markdown link's target — opens through `OpenFileContext`
* when the page provides one (the page resolves a relative one against its own
* 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.
@@ -82,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.
@@ -169,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;
@@ -225,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) {
@@ -237,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;
}
@@ -256,7 +265,25 @@ 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.
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
// 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 Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
const WIN_PATH_RE =
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
// In inline code the whole text is the path, so a Windows one may hold spaces.
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
/** A path at the start of `text`, absolute or relative, or null. */
const matchPath = (text: string) =>
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
// A markdown link target that is a page, not a file.
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,
@@ -284,6 +311,20 @@ function UrlLink({ url }: { url: string }) {
);
}
/** 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);
@@ -292,7 +333,7 @@ function PathLink({ path, children }: { path: string; children: React.ReactNode
<button
type="button"
title={path}
onClick={() => open(path)}
onClick={() => open(stripLine(path))}
className={`${LINK_CLASS} text-left`}
>
{children}
@@ -307,7 +348,7 @@ export function linkify(text: string): React.ReactNode[] {
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) && PATH_RE.exec(rest)?.[0];
const path = !url && atWordStart(text, i) && matchPath(rest);
const hit = url || path;
if (!hit) {
buf += text[i++];
@@ -359,7 +400,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
</code>
);
push(
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
@@ -408,9 +449,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"
@@ -431,10 +484,10 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
}
// An absolute path at a word start
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
if (pathM) {
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
i += pathM[0].length;
push(<PathLink path={pathM}>{pathM}</PathLink>);
i += pathM.length;
continue;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
/**
* @file AgentDetails.test.tsx
* @description Verifies what the chat shows of a subagent: an Agent step's
* facts, task and report (from its result, or the task-notification that
* closed a background one), and that "View conversation" opens that
* agent's own conversation in place, rendered by the chat's owner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageList } from "../MessageList";
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
const call: TranscriptContent = {
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
};
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
ids: new Map(),
notices: new Map(),
live: false,
...p,
});
describe("agentRef", () => {
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
const a = agentRef(
call,
{
type: "tool_result",
id: "t1",
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
agent: {
id: "a1b2",
model: "claude-opus-5-5",
status: "completed",
duration_ms: 65_000,
tokens: 12_000,
tool_uses: 7,
},
},
links()
);
expect(a).toMatchObject({
agentId: "a1b2",
type: "Explore",
status: "done",
background: false,
prompt: "Read **server/**",
report: "The hooks live in server/routes/hooks.js.",
durationMs: 65_000,
tokens: 12_000,
toolUses: 7,
});
});
it("closes a background agent with its notification's report and usage", () => {
const notice = m({
content: [
{
type: "text",
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
},
],
});
const launched: TranscriptContent = {
type: "tool_result",
id: "t1",
output: "Async agent launched successfully.\nagentId: a9b4",
};
const running = agentRef(call, launched, links({ live: true }));
expect(running).toMatchObject({ status: "running", background: true, report: null });
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
expect(done).toMatchObject({
agentId: "a9b4",
status: "done",
report: "All done",
tokens: 88007,
toolUses: 31,
durationMs: 132350,
});
});
it("finds the transcript from the session's list when the result names none", () => {
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
});
it("parses the line form of a usage block", () => {
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
tokens: 500,
toolUses: 3,
durationMs: 900,
});
});
});
describe("an Agent step in the chat", () => {
it("shows the agent's facts and report, and opens its conversation in place", () => {
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
render(
<MessageList
loading={false}
agentLinks={links({ render: render_ })}
messages={[
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
m({
content: [
{
type: "tool_result",
id: "t1",
output: "Found **3** hooks",
agent: {
id: "a1b2",
model: null,
status: "completed",
duration_ms: null,
tokens: null,
tool_uses: 4,
},
},
],
}),
]}
/>
);
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
expect(screen.queryByTestId("agent-chat")).toBeNull();
fireEvent.click(screen.getByTestId("agent-open"));
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
// The thread takes the report's place.
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
});
});
@@ -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,74 @@
/**
* @file ConversationView.pushed.test.tsx
* @description The server says a session's transcript grew
* (`transcript_changed`): the chat showing that session reads the new lines
* at once, and again within a short window after a burst — not at the hook
* throttle's 1.5 s pace — while another session's notice fetches nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, 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-21T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
const pushed = (session_id: string) =>
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView on transcript_changed", () => {
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
pushed("s1");
expect(calls()).toBe(before + 1);
pushed("s1");
pushed("s1");
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(calls()).toBe(before + 2);
});
it("ignores another session's transcript", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
pushed("s2");
await act(async () => {});
expect(calls()).toBe(before);
});
});
@@ -7,8 +7,66 @@
*/
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 Windows path, bare or in inline code", () => {
const open = withOpen(
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
);
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
expect(open.mock.calls).toEqual([
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
["C:/src/a.ts"],
["D:\\My Docs\\b.md"],
]);
});
it("opens a markdown link to a file instead of navigating", () => {
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
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", () => {
@@ -109,4 +167,18 @@ describe("<MarkdownContent />", () => {
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,118 @@
/**
* @file MessageList.agents.test.tsx
* @description Verifies a live chat's background-agents card reads each
* running agent's own transcript (by the agentId its launch result names)
* and shows the step it is on and its context size (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageList, agentProgress } from "../MessageList";
import { api } from "../../../lib/api";
import type { TranscriptMessage } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
});
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
// The subagent's own transcript: its task, then a search still running.
const tail: TranscriptMessage[] = [
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
content: [
{
type: "tool_use",
id: "g1",
name: "Grep",
input: { pattern: "copyId", description: "Searching for copyId" },
},
],
}),
];
const main: TranscriptMessage[] = [
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
}),
m({
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
]),
},
],
}),
m({
type: "assistant",
sender: "assistant",
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting" }],
}),
];
describe("background agents' progress", () => {
const rects = Element.prototype.getClientRects;
beforeEach(() => {
// jsdom lays nothing out: make the card count as on screen.
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
vi.mocked(api.sessions.transcript).mockResolvedValue({
messages: tail,
total: 2,
has_more: false,
first_line: 1,
last_line: 2,
});
});
afterEach(() => {
Element.prototype.getClientRects = rects;
vi.mocked(api.sessions.transcript).mockReset();
});
it("reads the step an agent is on and its context", () => {
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
});
it("shows them on the agent's line, from its own transcript", async () => {
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
await waitFor(() =>
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
"Searching for copyId"
)
);
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
agent_id: "a4118367b503adecc",
limit: 20,
});
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
});
it("reads nothing without the session", () => {
render(<MessageList live loading={false} messages={main} />);
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
expect(api.sessions.transcript).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@
* @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, "declined" when they refused it, nothing yet while 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>
*/
@@ -67,6 +67,25 @@ describe("an AskUserQuestion card", () => {
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?"])]} />
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
call("2", "Write", "/w/b.json"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
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");
fireEvent.click(within(card).getByTitle("/w/b.json"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
});
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
const onOpenFile = vi.fn();
const onOpenArtifact = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
messages={[
user("make a page"),
call("1", "Write", "/w/page.html"),
result("1", { added: 20, removed: 0 }),
call("2", "Edit", "/w/App.tsx"),
result("2", { added: 1, removed: 1 }),
user("thanks"),
]}
/>
);
const cards = screen.getAllByTestId("artifact-card");
// A .tsx it only edited is project code, not an artifact.
expect(cards).toHaveLength(1);
expect(cards[0]!.textContent).toContain("page");
fireEvent.click(cards[0]!);
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
});
it("drags a row as its @path attachment, and the header as every file", () => {
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 1, removed: 0 }),
call("2", "Write", "/w/b.md"),
call("2", "Write", "/w/b.json"),
result("2", { added: 2, removed: 0 }),
]}
/>
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
});
return data;
};
const one = drag(within(card).getByTitle("/w/b.md"));
const one = drag(within(card).getByTitle("/w/b.json"));
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
{ path: "@/w/b.md", name: "b.md" },
{ path: "@/w/b.json", name: "b.json" },
]);
expect(one["text/plain"]).toBe("/w/b.md");
expect(one["text/plain"]).toBe("/w/b.json");
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"]);
).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
});
@@ -5,12 +5,13 @@
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip.
* shows as a chip. A live chat lists the background subagents still running
* and does not call their turn done (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -137,7 +138,28 @@ describe("MessageList — sender attribution", () => {
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
// In the hover bar's tooltip, beside the model and time.
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
});
it("shows a local command as one line with its output, never its caveat", () => {
const line = (text: string, sender: "user" | "system" = "user") =>
msg({ type: "user", sender, content: [{ type: "text", text }] });
render(
<MessageList
loading={false}
messages={[
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
line("<command-name>/model</command-name><command-args>opus</command-args>"),
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
]}
/>
);
const row = screen.getByTestId("chat-command");
expect(row).toHaveTextContent("/model opus");
expect(row).toHaveTextContent("Set model to Opus");
expect(screen.queryByText(/Caveat/)).toBeNull();
expect(screen.queryByTestId("chat-user")).toBeNull();
});
it("ends a live chat with the step still running", () => {
@@ -167,7 +189,9 @@ describe("MessageList — sender attribution", () => {
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite");
// Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
// (the output shows on hover).
expect(row).toHaveTextContent(/1m \ds/);
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
@@ -340,7 +364,88 @@ describe("MessageList — sender attribution", () => {
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// A local command (`/clear`) is answered by the CLI itself: no turn.
const local = (text: string) =>
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
const clear = local("<command-name>/clear</command-name>");
const out = local("<local-command-stdout></local-command-stdout>");
expect(state([clear, out])).toBeNull();
// A prompt command (a skill) is still answered by the model.
expect(state([clear])).toEqual(thinking);
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
describe("background subagents", () => {
const at = new Date().toISOString();
const launch = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
});
const launched = msg({
timestamp: at,
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
},
],
});
const reply = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting for the agent" }],
});
const notified = msg({
sender: "system",
timestamp: at,
content: [
{
type: "text",
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
},
],
});
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
it("lists an agent until its notification names the call", () => {
const results = (m: TranscriptMessage[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
const all = [...base, notified];
expect(backgroundAgents(all, results(all))).toEqual([]);
});
it("does not call the turn done while the agent works", () => {
const { rerender } = render(<MessageList live loading={false} messages={base} />);
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
"ExploreChecking copyId coverage"
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
});
it("shows nothing once the session is no longer live", () => {
render(<MessageList loading={false} messages={base} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
});
});
});
@@ -103,6 +103,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib/clipboard";
// ───────────────────────── Copy button ─────────────────────────
@@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API can fail in insecure contexts - silently ignore.
}
if (!(await copyText(text, true))) return;
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
+234 -98
View File
@@ -1,13 +1,18 @@
/**
* @file AddLaneModal.tsx
* @description The "+ Add lane" flow, in one of two modes chosen with a
* @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; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
@@ -21,15 +26,15 @@
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";
import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { readDefaultTool } from "../../lib/sessionDefaults";
/** Polls the lane until the background `git worktree add` finishes (status
@@ -48,6 +53,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,
@@ -97,7 +113,21 @@ export function AddLaneModal({
cwdSuggestions: CwdSuggestion[];
}) {
const { t } = useTranslation(["lanes"]);
const [mode, setMode] = useState<"repo" | "worktree">("repo");
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 [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
token: string;
remember: boolean;
} | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [sourceRepo, setSourceRepo] = useState("");
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
@@ -110,27 +140,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();
// The account's own pick (first-run setup), while it is installed and on.
const ownTool = readDefaultTool();
const defaultTool =
const tool =
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
? ownTool
: null) ??
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 noTools = aiTools.loaded && aiTools.defaultId === null;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -141,6 +162,11 @@ export function AddLaneModal({
const reset = () => {
setMode("repo");
setCloneUrl("");
setCloneName("");
setClonePartial(false);
setLogin(null);
setSshRefused(false);
setSourceRepo("");
setTitle("");
setBranch("");
@@ -148,7 +174,6 @@ export function AddLaneModal({
setWorktrees([]);
setBranchMode("new");
setBase("");
setPickedTool(null);
setBranchesError(null);
setError(null);
setBusy(false);
@@ -213,6 +238,58 @@ export function AddLaneModal({
setBusy(true);
setError(null);
if (mode === "clone") {
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
try {
const result = await api.lanes.clone({
progressToken,
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
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);
} finally {
setCloneToken(null);
}
return;
}
if (mode === "repo") {
try {
const result = await api.lanes.ensure({
@@ -328,7 +405,6 @@ export function AddLaneModal({
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const browseRef = useRef<HTMLButtonElement>(null);
useDialog(open, panelRef, handleCancel, cancelRef);
// A summary on screen means the lane already exists and a step failed, so
@@ -338,6 +414,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()))));
@@ -394,81 +472,58 @@ 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" data-tour="add-lane-folder">
{/* A click into the path opens the picker, as Browse does (the
OS's own where it can be); typing still works from the keyboard. */}
<div
className="min-w-0 flex-1"
onClick={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
}}
>
{/* 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"
value={sourceRepo}
@@ -476,18 +531,87 @@ export function AddLaneModal({
suggestions={cwdSuggestions}
/>
</div>
<PathPickButton
start={sourceRepo}
onPick={setSourceRepo}
showLabel
buttonRef={browseRef}
/>
<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"
/>
<label
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="add-lane-clone-partial"
checked={clonePartial}
onChange={(e) => setClonePartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
</div>
)}
{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. */}
@@ -701,6 +825,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>
@@ -721,7 +853,11 @@ export function AddLaneModal({
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,224 @@
/**
* @file CloneHereDialog.tsx
* @description The Files page's **New → Clone repository**: clones a URL into
* the folder the File Explorer has open, through the same
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
type Login = { host: string; username: string; token: string; remember: boolean };
export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [login, setLogin] = useState<Login | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useDialog(open, panelRef, busy ? () => {} : onClose);
useEffect(() => {
if (!open) return;
setUrl("");
setName("");
setLogin(null);
setSshRefused(false);
setBusy(false);
setError(null);
}, [open, parent]);
if (!open) return null;
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
setBusy(true);
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onCloned(result.lane, result.path);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
token: "",
remember: l?.remember ?? true,
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
} else {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
}
};
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={busy ? undefined : onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="fx-clone-dialog"
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="space-y-3"
>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
{t("clone.urlLabel")}
</label>
<input
id={`${titleId}-url`}
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://host/owner/repo.git"
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
{t("clone.nameLabel")}
</label>
<input
id={`${titleId}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={cloneDirName(url)}
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 break-all text-[11px] text-fg-muted">
{t("clone.parentHint", { path: into })}
</p>
</div>
{login && (
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="username"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<input
aria-label={t("clone.token")}
placeholder={t("clone.token")}
type="password"
autoComplete="current-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
disabled={busy}
/>
{t("clone.remember")}
</label>
</div>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
{sshRefused && (
<a href="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
)}
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
{t("fx.cancel")}
</button>
<button
type="submit"
disabled={busy || !url.trim()}
className="btn-primary text-xs"
data-testid="fx-clone-submit"
>
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
</button>
</div>
</form>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,93 @@
/**
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
const POLL_MS = 1000;
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
</p>
);
}
const phase = view.phase
? view.phase === "other"
? view.label || ""
: t(`clone.phase.${view.phase}`)
: t("clone.connecting");
const percent = view.percent ?? 0;
return (
<div data-testid="clone-progress" className="space-y-1">
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
<span className="truncate">
{phase}
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
)}
</p>
{view.phase && (
<div
className="h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={phase}
>
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
</div>
)}
</div>
);
}
@@ -0,0 +1,321 @@
/**
* @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. Before anything runs, each URL
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
* link while the account has no SSH key is not cloned at all — the user is
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
type Row = {
url: string;
state: "wait" | "busy" | "done" | "failed";
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
try {
const u = new URL(url);
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
} catch {
/* not a URL yet — the server's allowlist answers that one */
}
return null;
}
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
function missingFor(
url: string,
id: GitIdentity | null
): { kind: "ssh" | "https"; host: string } | null {
const target = cloneTarget(url);
if (!id || !target) return null;
if (target.kind === "ssh") {
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
return keys > 0 ? null : target;
}
return id.https.some((h) => h.host === target.host) ? null : target;
}
export function CloneSourcesForm({
laneId,
parent,
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 [partial, setPartial] = useState(false);
const [identity, setIdentity] = useState<GitIdentity | null>(null);
// An unknown identity (an older server, a failed read) checks nothing: the
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
const loadIdentity = useCallback(
() =>
Promise.resolve()
.then(() => api.gitIdentity.get())
.then(setIdentity)
.catch(() => {}),
[]
);
useEffect(() => {
void loadIdentity();
const onFocus = () => void loadIdentity();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [loadIdentity]);
const urls = (s: string) => [
...new Set(
s
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
),
];
const cloneAll = async (list: string[]) => {
if (busy || !list.length) return;
setBusy(true);
// An SSH link with no key on the account would only fail at the host:
// it is not tried, and its row says what to set up first.
const next: Row[] = list.map((url) => {
const miss = missingFor(url, identity);
return miss?.kind === "ssh"
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
: { url, state: "wait" };
});
const show = () => setRows([...next]);
show();
let asked: Login | null = null;
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
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 });
row.ssh = true;
} else {
row.note = e.message || String(err);
}
}
row.token = undefined;
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);
// Checked as the user types, so a missing key is said before Clone is pressed.
const missing = urls(text)
.map((u) => missingFor(u, identity))
.filter((m): m is NonNullable<typeof m> => m !== null);
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
const settingsLink = (
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
{t("clone.sshSettings")}
</a>
);
return (
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
<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>
<label
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="workspace-clone-partial"
checked={partial}
onChange={(e) => setPartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{sshHosts.length > 0 && (
<p
role="alert"
data-testid="workspace-clone-need-ssh"
className="text-[11px] text-status-danger"
>
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
</p>
)}
{httpsHosts.length > 0 && !login && (
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
<button
type="button"
className="underline"
onClick={() =>
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
}
>
{t("home.cloneEnterLogin")}
</button>
</p>
)}
{login && (
<div className="space-y-1.5 rounded border border-border-light p-2">
<div className="flex gap-2">
<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.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
{r.note}
{r.ssh && <> {settingsLink}</>}
</span>
)}
</li>
))}
</ul>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+644 -72
View File
@@ -1,20 +1,102 @@
/**
* @file A server-backed folder (or file) browser for the path inputs' Browse
* button. Browsers refuse to expose an absolute filesystem path from a native
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path, plus its files for `kind="file"`)
* instead — breadcrumb-free, just an up-one-level button and a
* click-to-descend list; in file mode a click on a file picks it.
* 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, File } 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,
@@ -30,20 +112,67 @@ export function FolderBrowseModal({
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 (kind === "file" ? api.lanes.browse(path, true) : 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
@@ -54,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"
@@ -64,82 +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={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 && (
<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"
>
<ArrowUp className="h-3.5 w-3.5" />
..
</button>
)}
{listing?.entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => {
if (!entry.file) return void load(entry.path);
onSelect(entry.path);
onClose();
}}
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.file ? (
<File className="h-3.5 w-3.5 text-fg-muted" />
) : 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"
{/* 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}
>
{t("destructive.cancel")}
</button>
{kind === "dir" && (
<bdi>{target ?? "…"}</bdi>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
setNewName("");
setCreating(true);
}}
className="btn-primary text-xs disabled:opacity-50"
disabled={!listing}
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
>
{t("browse.select")}
<FolderPlus className="h-3.5 w-3.5" />
{t("browse.newFolder")}
</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>
+277 -40
View File
@@ -87,6 +87,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Download,
Code2,
Columns2,
Eye,
@@ -103,7 +104,9 @@ import {
UnfoldVertical,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste";
import { readMarkdownPreview } from "../../lib/markdownView";
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
@@ -121,6 +124,7 @@ import {
type LaneDiffChange,
} from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
@@ -807,8 +811,12 @@ export function LaneChangesPanel({
wide = full,
onDirtyChange,
openRequest = null,
filesChanged = 0,
}: {
laneId: number;
/** Bumped when files changed outside this panel (the header's File
* Explorer): the tree is read again, its open folders kept open. */
filesChanged?: number;
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
openRequest?: { path: string; nonce: number } | null;
/** The whole-file editor holds unsaved text (true) or not (false). */
@@ -852,6 +860,9 @@ export function LaneChangesPanel({
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
const [range, setRange] = useState<LaneChangeRange>("work");
// Local mode: the files are already on this machine — no upload, download
// or File Explorer (the desktop's file manager does all three).
const { local } = usePermissions();
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
// An image opens as a picture, never as text or a binary diff.
@@ -863,10 +874,21 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
// A whole markdown file shown rendered instead of as source; per file, each
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads
// from the disk. Open there instead of on a page that only says "no git".
// Both reads must say so: the facts alone also read "unavailable" on a
// network error or an older server.
const notRepo = git?.available === false && data?.available === false;
useEffect(() => {
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
setMode(full ? "overview" : "browse");
}, [notRepo, mode, full]);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
@@ -939,6 +961,13 @@ export function LaneChangesPanel({
const [message, setMessage] = useState("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
// it (EPUSHED), so the box is not offered — asking first and failing after
// the confirm only costs the user a click.
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
useEffect(() => {
if (headPushed) setAmend(false);
}, [headPushed]);
// The files the commit takes (JetBrains' checkboxes). A path listed for the
// first time arrives with its default; `seenPaths` is the last full list,
// so a poll never re-ticks a file the user unticked.
@@ -1034,6 +1063,9 @@ export function LaneChangesPanel({
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (mode !== "history") return;
// A reply for the previous ref/file/page lands after the next request
// started: drop it, or it overwrites the list the user is looking at.
let live = true;
setHistoryLoading(true);
void api.lanes
.gitLog(target, {
@@ -1041,16 +1073,23 @@ export function LaneChangesPanel({
...(historyRef ? { ref: historyRef } : {}),
...(historyFile ? { file: historyFile } : {}),
})
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
.then(forLane(laneId, (r) => live && setCommits(r.commits)))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
.finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
return () => {
live = false;
};
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
useEffect(() => {
if (mode !== "history") return;
let live = true;
void api.lanes
.gitBranches(target)
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
.catch(() => setHistoryBranches([]));
.then(forLane(laneId, (r) => live && setHistoryBranches(r.branches)))
.catch(() => live && setHistoryBranches([]));
return () => {
live = false;
};
}, [mode, laneId, reread]);
// Re-run when a lane switch empties the cache too: the full-width sidebar
@@ -1068,19 +1107,32 @@ export function LaneChangesPanel({
}, [mode, laneId, query, reread, full, treeRootLoaded]);
// A folder's children are fetched lazily, the first time it is expanded.
const readDir = (path: string) =>
api.lanes
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
const toggleDir = (path: string) => {
const opening = !expandedDirs.has(path);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
if (path in treeChildren) return;
void api.lanes
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// Every OPEN reads the folder again (the cached level shows meanwhile):
// files land in it from outside the panel — an agent, the terminal, the
// File Explorer — and a level cached while it was empty stayed empty.
if (opening) void readDir(path);
};
// Files changed outside the panel: read the root and every open folder again.
useEffect(() => {
if (!filesChanged) return;
void readDir("");
for (const p of expandedDirs) void readDir(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesChanged]);
const [treeBusy, setTreeBusy] = useState(false);
// "Expand all" is ONE read of the whole tree: a request per folder meant a
@@ -1131,24 +1183,33 @@ export function LaneChangesPanel({
// own fetch — and only while the viewer is actually showing it.
useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile) return;
// Opening file B before file A's read came back must not show (and let the
// editor save) A's text under B's header.
let live = true;
void api.lanes
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then(
forLane(laneId, (r) =>
setText(
r.available
? {
path: r.path,
lines: r.lines,
truncated: r.truncated,
binary: r.binary,
sha: r.sha,
}
: null
)
forLane(
laneId,
(r) =>
live &&
setText(
r.available
? {
path: r.path,
lines: r.lines,
truncated: r.truncated,
binary: r.binary,
sha: r.sha,
}
: null
)
)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [mode, whole, editable, openFile, laneId, range, sha]);
// The editor's original side, read beside the working file. A file that is
// not there (new since) diffs against nothing: every line is added.
@@ -1156,10 +1217,17 @@ export function LaneChangesPanel({
useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
let live = true;
void api.lanes.gitFile(target, openFile, rev).then(
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
forLane(laneId, () => setBaseText(""))
forLane(
laneId,
(r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")
),
forLane(laneId, () => live && setBaseText(""))
);
return () => {
live = false;
};
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
useEffect(() => {
@@ -1382,6 +1450,27 @@ export function LaneChangesPanel({
}
};
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
}),
confirmLabel: t("changes.discardAll"),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
);
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
@@ -1614,8 +1703,32 @@ export function LaneChangesPanel({
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
{t("changes.showHistory")}
</MenuItem>
{!local && (
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
)}
</>
)}
{!local && treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-upload"
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
>
{t("changes.uploadHere")}
</MenuItem>
)}
{!local && treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-explorer"
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
>
{t("changes.openInExplorer")}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
</MenuItem>
@@ -1664,7 +1777,12 @@ export function LaneChangesPanel({
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
// Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
@@ -1861,6 +1979,61 @@ export function LaneChangesPanel({
};
const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
// upload only creates, into the folder it was asked for ("" = the root).
const uploadInput = useRef<HTMLInputElement>(null);
const uploadDir = useRef("");
const [, setUploading] = useState(false);
// The File Explorer (the toolbar button where Upload was); the tree is
// re-read when it closes after changing anything.
const [explorer, setExplorer] = useState<{ path: string } | null>(null);
const explorerChanged = useRef(false);
const download = (path: string) => {
const a = document.createElement("a");
a.href = api.lanes.gitDownloadUrl(target, path);
a.download = path.split("/").pop() || path;
a.click();
};
const pickUpload = (dir: string) => {
uploadDir.current = dir;
uploadInput.current?.click();
};
const upload = async (files: File[]) => {
if (!files.length) return;
const lane = laneId;
const dir = uploadDir.current;
setUploading(true);
let saved = 0;
try {
for (const f of files) {
try {
await api.lanes.gitUpload(target, dir, f);
saved++;
} catch (err) {
toast.error(
t("changes.uploadFailed", {
name: f.name,
message: err instanceof Error ? err.message : String(err),
})
);
}
}
} finally {
if (laneNow.current === lane) {
setUploading(false);
if (saved) {
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
// Re-read the level it landed in, and open it.
void api.lanes
.gitTree(target, dir)
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
.catch(() => undefined);
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
}
}
}
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
@@ -1869,6 +2042,8 @@ export function LaneChangesPanel({
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown file.
const mdShown = mdPreview && whole && isMarkdown(openFile);
dirtyRef.current = dirty;
useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange);
@@ -2047,6 +2222,36 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
{/* The File Explorer's button lives in the panel header now (beside
Full screen); this instance serves the tree's "Open in File
Explorer". */}
<FileExplorer
open={!!explorer}
laneId={laneId}
session={session}
source={source}
initialPath={explorer?.path ?? ""}
onChanged={() => (explorerChanged.current = true)}
onClose={() => {
setExplorer(null);
if (explorerChanged.current) {
explorerChanged.current = false;
void rereadAll();
}
}}
/>
<input
ref={uploadInput}
type="file"
multiple
hidden
data-testid="lane-changes-upload-input"
onChange={(e) => {
const files = [...(e.target.files ?? [])];
e.target.value = "";
void upload(files);
}}
/>
{!hits && (
<IconButton
label={
@@ -2148,14 +2353,16 @@ export function LaneChangesPanel({
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
) : notRepo && full ? null : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{/* Files is the whole repo's tree; at full width it is the
sidebar already, so no button. */}
{(full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m) => (
{(notRepo
? (["browse"] as const)
: full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m: Mode) => (
<button
key={m}
type="button"
@@ -2510,7 +2717,9 @@ export function LaneChangesPanel({
browsePane
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
) : !data.available && mode !== "file" ? (
// A file still opens from Files: its whole-file read is the disk's,
// not git's.
<p className="text-[11px] text-fg-muted">
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
</p>
@@ -2566,13 +2775,16 @@ export function LaneChangesPanel({
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label
title={t("changes.amendHint")}
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
type="checkbox"
data-testid="lane-changes-amend"
checked={amend}
disabled={headPushed}
onChange={(e) => setAmend(e.target.checked)}
/>
{t("changes.amend")}
@@ -2595,6 +2807,17 @@ export function LaneChangesPanel({
>
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
</button>
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
</button>
</div>
</div>
)}
@@ -2758,6 +2981,18 @@ export function LaneChangesPanel({
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{!local && range !== "commit" && openFile && (
<button
type="button"
data-testid="lane-file-download"
onClick={() => download(openFile)}
title={t("changes.download")}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
<Download className="h-3 w-3" />
{t("changes.download")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* A working file opens in the editor; the read-only diff
(Whole file off) keeps an Edit button back into it. */}
@@ -2822,7 +3057,7 @@ export function LaneChangesPanel({
range === "commit" && sha ? sha : "work"
)}
/>
) : !mdPreview &&
) : !mdShown &&
(whole || editable) &&
(draft !== null || (editable && text === null)) ? (
// The editor, once both texts are in: not the diff first.
@@ -2838,7 +3073,9 @@ export function LaneChangesPanel({
collapse={!whole}
revertLabel={t("changes.revertBlock")}
value={draft}
original={baseText ?? draft}
// Outside a repo there is no HEAD: the edits are
// shown against the text as it was opened.
original={(notRepo ? text?.lines.join("\n") : baseText) ?? draft}
lang={langFor(openFile ?? "")}
label={t("changes.editorLabel", { path: openFile })}
onChange={setDraft}
@@ -2876,7 +3113,7 @@ export function LaneChangesPanel({
</Suspense>
)
) : whole ? (
text && mdPreview ? (
text && mdShown ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
+18 -3
View File
@@ -242,13 +242,27 @@ export function LaneGitBar({
void run(action, body);
}
};
const push = () =>
confirmRun(
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: git.ahead ?? 0,
count: ahead,
branch: git.branch,
upstream: git.upstream,
}),
@@ -259,6 +273,7 @@ export function LaneGitBar({
},
"push"
);
};
const publish = () =>
confirmRun(
{
+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>
)}
+13 -5
View File
@@ -9,8 +9,9 @@
* first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude and Codex can resume a session by id —
* any other tool's session offers a new session from the workspace home
* it is live the composer is off. Only Claude, Codex and a catalog tool with
* `resumeById` (omp) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton";
import { useAiTools } from "../../hooks/useAiTools";
/** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation);
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
}
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
// Before the catalog loads: the two hand-written launchers resume by id.
const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({
@@ -65,6 +68,7 @@ export function SessionReplay({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { byId } = useAiTools();
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
@@ -88,9 +92,9 @@ export function SessionReplay({
}, [sessionId]);
const tool = session?.tool || "claude";
const resumable = RESUMABLE.has(tool);
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, tool === "claude");
const send = async () => {
if (!text || busy || uploading || livePane || !resumable) return;
@@ -132,7 +136,11 @@ export function SessionReplay({
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
<ConversationView
sessionId={sessionId}
fill
ended={!livePane && !!session && session.status !== "active"}
/>
</div>
{livePane ? (
<div
@@ -1,7 +1,8 @@
/**
* @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume and Fork for a Claude
* session inside a workspace. Opening a session never resumes it; this is the
* session inside a workspace (Resume only for a catalog tool that resumes by
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
* one place that does, behind a warning, because a resume re-reads the whole
* transcript into context. Fork resumes it into a NEW session id
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate";
import { useAiTools } from "../../hooks/useAiTools";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -54,6 +56,7 @@ export function SessionResumeButton({
const navigate = useNavigate();
const { lanes } = useLanes();
const { canEditLane } = usePermissions();
const { byId } = useAiTools();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false);
@@ -70,12 +73,13 @@ export function SessionResumeButton({
};
}, [session.id]);
// Claude only: a Codex session id is not what
// `codex resume` takes for every kind of thread.
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
// Codex session id is not what `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
// The workspace that owns it, else the one whose folder holds it.
const lane =
tool !== "claude" || !session.cwd
(tool !== "claude" && !catalogResume) || !session.cwd
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
@@ -140,7 +144,7 @@ export function SessionResumeButton({
{t("sessions.resume")}
</button>
)}
{resumable && (
{resumable && tool === "claude" && (
<button
type="button"
onClick={() => setConfirming("fork")}
@@ -4,7 +4,8 @@
* /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.
* 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>
*/
@@ -21,6 +22,7 @@ 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 "";
@@ -436,6 +438,16 @@ function ContextBody({
)}
</form>
)}
{folderOpen && (
<CloneSourcesForm
laneId={lane.id}
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
onAdded={(next) => {
setView(next);
onSourcesChange?.(next);
}}
/>
)}
</section>
</aside>
+28 -16
View File
@@ -11,7 +11,7 @@
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
@@ -20,7 +20,6 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useEffect, useRef, useState } from "react";
@@ -34,6 +33,7 @@ import {
MoreHorizontal,
ArrowUp,
SquareTerminal,
Zap,
} from "lucide-react";
import {
api,
@@ -76,7 +76,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -165,12 +165,14 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
// The account's own default (first-run setup) — never bypass.
// The account's own defaults (first-run setup, Settings → AI tools →
// Session defaults); a pick here is for this session only.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
// Off for every tool but Codex, which starts from the account's default.
const [bypass, setBypass] = useState(false);
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
@@ -254,7 +256,7 @@ function HomeBody({
}
};
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, claude);
const start = async () => {
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
@@ -607,15 +609,25 @@ function HomeBody({
</div>
)}
{canBypass && (
<span className="px-2">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
// A chip like the selects beside it: quiet when off, red when on.
<button
type="button"
aria-pressed={bypass}
data-testid="workspace-home-bypass"
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
onClick={() => setBypass(!bypass)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
bypass
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Zap
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
aria-hidden
/>
</span>
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
</button>
)}
</div>
{danger && (
@@ -1,12 +1,13 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
* pane above the ended ones) — never the other
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
* pinned ones on top in pin order, then the ones with a live pane, then the
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
* which deletes nothing); a right-click opens the same actions and more
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page
@@ -33,7 +34,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
3000
);
const refreshKey = `${hookKey}:${version}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const own = useSessionPage(
{ lane: lane.id, archived: false, pinnedFirst: true, limit },
refreshKey
);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest
// first (the sort is stable over the server's order).
// Pinned sessions first, then live ones, then ended ones — each group in
// the server's order (pin order, else newest first; the sort is stable).
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
.sort((a, b) => rank(a) - rank(b));
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
if (count !== null) onMatches?.(count);
}, [count, onMatches]);
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
const patch = async (
s: Session,
body: { name?: string; archived?: boolean; pinned?: boolean }
) => {
setError(null);
try {
await api.sessions.patch(s.id, body);
@@ -533,6 +541,13 @@ export function WorkspaceSessionList({
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{s.pinned_at && (
<Pin
data-testid={`workspace-session-pinned-${s.id}`}
aria-label={t("tree.pinned")}
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
{sessionWorktreeName(lane, s) && (
<GitBranch
data-testid={`workspace-session-worktree-${s.id}`}
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
<span
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
>
{timeAgo(s.last_activity ?? s.started_at, true)}
</span>
<AiToolIcon
@@ -548,6 +565,22 @@ export function WorkspaceSessionList({
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/>
</button>
{!readOnly && (
<button
type="button"
data-testid={`workspace-session-pin-${s.id}`}
onClick={() => void patch(s, { pinned: !s.pinned_at })}
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
className="absolute right-6 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
{s.pinned_at ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
</button>
)}
{!readOnly && (
<button
type="button"
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => void archive(sessionCtx.menu!.item)}
onPin={() =>
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
}
/>
)}
</>
@@ -25,8 +25,11 @@ vi.mock("../../../lib/api", () => ({
pipelines: vi.fn(),
worktree: vi.fn(),
ensure: vi.fn(),
clone: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
// The folder browser's Places list the user's workspaces.
list: vi.fn(() => Promise.resolve({ lanes: [], counts: {} })),
get: vi.fn(),
profileInit: vi.fn(),
mcpSync: vi.fn(),
@@ -154,6 +157,7 @@ beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.clone).mockReset();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
@@ -582,17 +586,15 @@ describe("AddLaneModal — folder browse", () => {
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("opens the picker on every click into the path field", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
renderModal();
const user = userEvent.setup();
const field = document.getElementById("add-lane-repo")!;
await user.click(field);
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
await user.click(field);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
expect(field).toHaveFocus();
expect(api.lanes.pickPath).not.toHaveBeenCalled();
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("falls back to the in-app browser when no native picker is available", async () => {
@@ -614,7 +616,7 @@ describe("AddLaneModal — folder browse", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
it("opens the browser, lists subdirectories, and a double-click opens one", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
@@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
await user.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByText("projects")).toBeInTheDocument();
await user.click(screen.getByText("projects"));
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
await user.dblClick(screen.getByText("projects"));
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false, false);
});
it("Escape closes only the folder browser, not the whole modal", async () => {
@@ -668,46 +670,18 @@ describe("AddLaneModal — create/update readme", () => {
describe("AddLaneModal — no proxy here", () => {
it("asks for no AI Proxy: a new session picks its own", async () => {
renderModal({});
await screen.findByRole("button", { name: /Claude Code.*Default/ });
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
expect(api.aiProxies.list).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — tool selection", () => {
it("sends the picked tool when adopting a repo", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
// One button showing the default; it opens every installed + enabled tool.
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
expect(
within(list)
.getAllByRole("button")
.map((b) => b.textContent)
).toEqual(["Claude Code", "Codex", "Gemini"]);
await user.click(within(list).getByRole("button", { name: "Codex" }));
// The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
);
it("offers no tool picker: a new session picks its own AI", async () => {
renderModal({});
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /Claude Code/ })).toBeNull();
expect(screen.queryByRole("button", { name: "Codex" })).toBeNull();
});
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
@@ -779,3 +753,92 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
);
});
});
describe("AddLaneModal — Clone", () => {
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
renderModal();
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, url);
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
}
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
const onAdded = vi.fn();
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 5 }),
created: true,
path: "/home/an/code/repo",
});
const user = userEvent.setup();
renderModal({ onAdded });
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, "git@github.com:owner/repo.git");
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
created: true,
path: "/home/an/code/big",
});
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/big.git");
await user.click(screen.getByTestId("add-lane-clone-partial"));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
);
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("needs a login"), {
code: "ECREDENTIALS",
auth: { protocol: "https", host: "git.example.com" },
})
)
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const login = await screen.findByTestId("add-lane-clone-login");
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
await user.type(within(login).getByLabelText("Username"), "an");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
username: "an",
password: "tok",
remember: true,
})
);
});
it("points to Settings → Git when the SSH key is refused", async () => {
vi.mocked(api.lanes.clone).mockRejectedValue(
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
);
const user = userEvent.setup();
await fillClone(user, "git@github.com:o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
});
});
@@ -0,0 +1,79 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
detail: null,
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -0,0 +1,120 @@
/**
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
* an extra folder, a host that wants a login opens the login row, and Retry
* re-runs only the failed URLs with that login. An SSH link while the
* account has no key is never sent — the user is pointed at Settings → Git —
* and an HTTPS host with no saved login is flagged before Clone. The
* partial-clone box adds `partial: true` to every clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
}));
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => {
beforeEach(() => {
vi.mocked(api.lanes.cloneFolder).mockReset();
// Unknown identity: nothing is checked up front.
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
});
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
const user = userEvent.setup();
const onAdded = vi.fn();
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
if (body?.url.includes("private") && !body.credentials)
throw Object.assign(new Error("login"), {
code: "ECREDENTIALS",
auth: { protocol: "https", host: "private.test" },
});
return view;
});
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
await user.type(
screen.getByTestId("workspace-clone-input"),
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
);
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
expect(onAdded).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
// Only what is left stays in the box.
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
await user.type(screen.getByLabelText("Username"), "dev");
await user.type(screen.getByLabelText("Password or access token"), "tok");
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
await user.click(screen.getByTestId("workspace-clone-partial"));
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
url: "https://h/big.git",
partial: true,
})
);
});
it("tells an SSH link from an HTTPS one", () => {
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
expect(cloneTarget("not a url")).toBeNull();
});
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
const user = userEvent.setup();
vi.mocked(api.gitIdentity.get).mockResolvedValue({
ssh: null,
sshKeys: [],
https: [],
} as unknown as GitIdentity);
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
await user.type(
screen.getByTestId("workspace-clone-input"),
"git@github.com:o/r.git\nhttps://h.test/a.git"
);
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
});
});
@@ -0,0 +1,557 @@
/**
* @file Tests for the workspace File Explorer modal: it lists a folder and
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, restores from the user's own
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileExplorer } from "../FileExplorer";
import { api, type FsListing } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
source ? { lane, source } : session ? { lane, session } : lane,
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
api: {
lanes: {
list: vi.fn(),
clone: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
homes: vi.fn(),
downloadUrl: (t: unknown, p: string) => `/dl/${JSON.stringify(t)}/${p}`,
list: vi.fn(),
mkdir: vi.fn(),
newFile: vi.fn(),
rename: vi.fn(),
move: vi.fn(),
copy: vi.fn(),
remove: vi.fn(),
trash: vi.fn(),
allTrash: vi.fn(),
restore: vi.fn(),
purge: vi.fn(),
upload: vi.fn(),
uploadBatch: vi.fn(),
uploadCheck: vi.fn(),
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
},
},
}));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const root: FsListing = {
path: "",
parent: null,
truncated: false,
entries: [
{ name: "src", path: "src", type: "dir", size: null, mtime: 0 },
{ name: "a.txt", path: "a.txt", type: "file", size: 12, mtime: 0 },
],
};
const src: FsListing = {
path: "src",
parent: "",
truncated: false,
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{ id: 2, title: "two", cwd: "/w/two" },
],
counts: {},
} as never);
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
vi.mocked(api.files.homes).mockResolvedValue({
homes: [
{ id: "me", label: "An", readOnly: false },
{ id: "bob", label: "Bob", readOnly: true },
],
});
});
const open = () => render(<FileExplorer open laneId={1} onClose={vi.fn()} onChanged={vi.fn()} />);
const row = (name: string) =>
screen.getAllByTestId("fx-row").find((r) => r.textContent?.includes(name))!;
describe("FileExplorer", () => {
it("hides dot files until Ctrl+H, like the Linux file manager", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: ".env.example", path: ".env.example", type: "file", size: 1, mtime: 0 },
],
});
open();
await screen.findByText("a.txt");
expect(screen.queryByText(".env.example")).toBeNull();
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "h", ctrlKey: true });
expect(await screen.findByText(".env.example")).toBeInTheDocument();
});
it("shows a labelled action bar for a selection, and goes Back after opening a folder", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(row("a.txt"));
const bar = screen.getByTestId("fx-actionbar");
expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
expect(within(bar).getByRole("button", { name: /Move to Trash/ })).toBeInTheDocument();
fireEvent.doubleClick(row("src"));
await screen.findByText("x.js");
await user.click(screen.getByRole("button", { name: "Back" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
});
it("lists a folder and opens a sub-folder on double-click", async () => {
open();
expect(await screen.findByText("a.txt")).toBeInTheDocument();
fireEvent.doubleClick(row("src"));
expect(await screen.findByText("x.js")).toBeInTheDocument();
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
});
it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "New" }));
await user.click(screen.getByRole("menuitem", { name: "New folder" }));
const name = screen.getByLabelText("Name");
await user.clear(name);
await user.type(name, "docs{Enter}");
await waitFor(() => expect(api.files.mkdir).toHaveBeenCalledWith(1, "docs"));
});
it("deletes into the Trash without asking", async () => {
vi.mocked(api.files.remove).mockResolvedValue({ trashed: ["id1"] });
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
await waitFor(() => expect(api.files.remove).toHaveBeenCalledWith(1, ["a.txt"]));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("copies here and pastes into another workspace", async () => {
vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
await user.click(await screen.findByRole("button", { name: "two" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
await waitFor(() =>
expect(api.files.copy).toHaveBeenCalledWith(2, ["a.txt"], "", {
fromLane: 1,
conflict: "fail",
})
);
});
it("asks about a taken name on upload, and sends the choice", async () => {
vi.mocked(api.files.upload)
.mockRejectedValueOnce(
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
)
.mockResolvedValueOnce({ path: "a (1).txt" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const file = new File(["hi"], "a.txt");
await user.upload(screen.getByTestId("fx-upload-files"), file);
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
await waitFor(() =>
expect(api.files.upload).toHaveBeenLastCalledWith(
1,
"a.txt",
file,
"keep",
expect.any(Function)
)
);
});
it("sends many small files as one batch, asking once about every taken name", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Replace" }));
await waitFor(() =>
expect(api.files.uploadBatch).toHaveBeenCalledWith(
1,
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.any(Function)
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
expect(api.files.upload).not.toHaveBeenCalled();
});
it("falls back to one request per file when the server cannot take a batch", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
const big = new File(["x"], "big.bin");
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(api.files.upload).not.toHaveBeenCalled();
});
it("restores from the Trash", async () => {
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "abc12345",
name: "a.txt",
laneId: 1,
path: "a.txt",
isDir: false,
deletedAt: "2026-10-14T00:00:00Z",
},
],
});
vi.mocked(api.files.restore).mockResolvedValue({ path: "a.txt", laneId: null });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
const trashRow = await screen.findByTestId("fx-trash-row");
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
await waitFor(() =>
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
);
});
it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => {
vi.mocked(api.files.allTrash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
home: "bob",
owner: "Bob",
},
],
errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
});
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
},
],
});
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const trashes = screen.getByTestId("fx-trashes");
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
const folders = await screen.findAllByTestId("fx-trash-folder");
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
await user.click(folders[1]!);
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
const trashRow = await screen.findByTestId("fx-trash-row");
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
)
);
expect(api.files.purge).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
await waitFor(() =>
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
);
// Back to the folders through the path bar.
await user.click(
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
name: "Trash",
})
);
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
});
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
vi.mocked(api.files.allTrash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
home: "bob",
owner: "Bob",
},
],
errors: [],
});
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
},
],
});
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
const folders = await screen.findAllByTestId("fx-trash-folder");
// My Trash is empty: its Empty Trash is disabled.
fireEvent.contextMenu(folders[0]!);
expect(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
).toHaveProperty("disabled", true);
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.contextMenu(folders[1]!);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
);
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
);
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
await user.click(
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
);
await waitFor(() =>
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
);
});
it("right-clicks a sidebar workspace and a path crumb", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
);
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
);
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
});
it("shows why a Trash could not be read instead of an empty one", async () => {
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
expect(screen.queryByText("The Trash is empty.")).toBeNull();
});
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(await screen.findByRole("button", { name: "Home" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith({ home: "me" }, ""));
fireEvent.click(row("a.txt"));
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
await user.click(screen.getByRole("button", { name: "one" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
await waitFor(() =>
expect(api.files.copy).toHaveBeenCalledWith(1, ["a.txt"], "", {
fromHome: "me",
conflict: "fail",
})
);
});
it("shows another user's home read-only: no New, Upload, Cut, Rename or Delete", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(await screen.findByRole("button", { name: "Bob" }));
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
await user.click(row("a.txt"));
const bar = screen.getByTestId("fx-actionbar");
expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
expect(within(bar).getByRole("button", { name: /Copy/ })).toBeInTheDocument();
expect(within(bar).queryByRole("button", { name: /Cut|Rename|Move to Trash/ })).toBeNull();
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
expect(api.files.remove).not.toHaveBeenCalled();
});
});
describe("FileExplorer — New → Clone repository", () => {
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "New" }));
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
return screen.getByTestId("fx-clone-dialog");
};
it("clones into the open folder of the workspace and says where", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
created: true,
path: "/w/one/src/repo",
} as never);
const user = userEvent.setup();
open();
fireEvent.doubleClick(await screen.findByText("src"));
await screen.findByText("x.js");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith({
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
)
.mockResolvedValueOnce({
lane: { id: 9, title: "r", cwd: "/w/one/r" },
created: true,
path: "/w/one/r",
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
const login = await within(dialog).findByTestId("fx-clone-login");
await user.type(within(login).getByLabelText("Username"), "ann");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenLastCalledWith(
expect.objectContaining({
parent: "/w/one",
credentials: { username: "ann", password: "tok", remember: true },
})
)
);
});
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
const dialog = await openClone(user);
const url = within(dialog).getByLabelText("Repository URL");
await user.type(url, "abc{Backspace}{Delete}");
expect(api.files.remove).not.toHaveBeenCalled();
expect(url).toHaveValue("ab");
});
it("is not offered in an extra folder", async () => {
const user = userEvent.setup();
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "New" }));
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
});
});
@@ -0,0 +1,206 @@
/**
* @file FolderBrowseModal.test.tsx
* @description Unit tests for the in-app folder browser laid out like a
* native picker: a click selects and Select picks that folder (else the
* current one), a double-click opens, Backspace and the breadcrumb go up,
* the filter narrows the list, Places open Home and a workspace, the path
* field (Ctrl+L) opens a typed path, New folder makes one and selects it,
* Show hidden re-lists with dot entries, and a Select is remembered as Recent.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../../lib/api";
import { FolderBrowseModal } from "../FolderBrowseModal";
vi.mock("../../../lib/api", () => ({
api: { lanes: { browse: vi.fn(), mkdir: vi.fn(), list: vi.fn() } },
}));
const TREE: Record<string, string[]> = {
"/home/u": ["proj", "notes"],
"/home/u/proj": ["api", "web"],
"/home/u/proj/api": [],
"/home/u/proj/web": [],
"/home/u/notes": [],
"/srv/ws": [],
};
function listing(p: string, hidden = false) {
const names = [...(TREE[p] ?? []), ...(hidden ? [".cfg"] : [])];
return {
path: p,
parent: p === "/home/u" ? null : p.slice(0, p.lastIndexOf("/")) || "/",
home: "/home/u",
entries: names.map((n, i) => ({
name: n,
path: `${p}/${n}`,
isGitRepo: n === "api",
mtime: 1_700_000_000_000 + i,
})),
};
}
beforeEach(() => {
localStorage.clear();
vi.mocked(api.lanes.browse)
.mockReset()
.mockImplementation(async (p?: string, _f?: boolean, hidden?: boolean) => {
const at = p || "/home/u";
if (!(at in TREE)) throw new Error("path does not exist");
return listing(at, hidden);
});
vi.mocked(api.lanes.mkdir).mockReset();
vi.mocked(api.lanes.list)
.mockReset()
.mockResolvedValue({
lanes: [{ id: 1, title: "Shop API", cwd: "/srv/ws" }],
counts: {},
} as never);
});
const open = (onSelect = vi.fn(), initialPath?: string) => {
render(
<FolderBrowseModal open initialPath={initialPath} onSelect={onSelect} onClose={vi.fn()} />
);
return screen.findByRole("dialog", { name: "Browse for a folder" });
};
const option = (dialog: HTMLElement, name: string) =>
within(dialog).findByRole("option", { name: new RegExp(name) });
describe("FolderBrowseModal", () => {
it("opens an admin in their own home; the Server folder tick opens the host's", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.browse).mockImplementation(async (p, _f, _h, host) => ({
...listing(p || (host ? "/home/u" : "/srv/ws"), false),
home: host ? "/home/u" : "/srv/ws",
hostHome: "/home/u",
}));
const dialog = await open();
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, false)
);
const tick = within(dialog).getByTestId("browse-host-folder");
expect(tick).not.toBeChecked();
await user.click(tick);
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, true)
);
expect(tick).toBeChecked();
});
it("shows no Server folder tick without a host home in the listing", async () => {
const dialog = await open();
expect((await within(dialog).findAllByRole("option")).length).toBeGreaterThan(0);
expect(within(dialog).queryByTestId("browse-host-folder")).toBeNull();
});
it("click selects, double-click opens, Select picks the selection else the folder", async () => {
const onSelect = vi.fn();
const dialog = await open(onSelect);
await userEvent.click(await option(dialog, "proj"));
expect(await option(dialog, "proj")).toHaveAttribute("aria-selected", "true");
await userEvent.dblClick(await option(dialog, "proj"));
await option(dialog, "web");
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
});
it("Enter opens the selected folder and Backspace goes back up to it", async () => {
const dialog = await open();
const list = within(dialog).getByRole("listbox");
await userEvent.click(await option(dialog, "proj"));
await userEvent.keyboard("{Enter}");
await option(dialog, "api");
list.focus();
await userEvent.keyboard("{Backspace}");
await waitFor(() =>
expect(within(dialog).getByRole("option", { name: /proj/ })).toHaveAttribute(
"aria-selected",
"true"
)
);
});
it("the breadcrumb, Back and Forward move between folders", async () => {
const dialog = await open(vi.fn(), "/home/u/proj/api");
await userEvent.click(within(dialog).getByRole("button", { name: "proj" }));
await option(dialog, "web");
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false, false)
);
await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ }));
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false, false)
);
// Above the user's own folder (it answered no parent) nothing is a link.
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
await option(dialog, "notes");
expect(within(dialog).getByRole("button", { name: "home" })).toBeDisabled();
});
it("the filter narrows the list, and Enter on a single match opens it", async () => {
const dialog = await open();
await option(dialog, "notes");
await userEvent.type(within(dialog).getByRole("textbox", { name: "Filter…" }), "pro");
expect(within(dialog).queryByRole("option", { name: /notes/ })).toBeNull();
await userEvent.keyboard("{Enter}");
await option(dialog, "web");
});
it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => {
const dialog = await open(vi.fn(), "/home/u/proj");
await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ }));
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false, false)
);
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
await option(dialog, "notes");
await userEvent.keyboard("{Control>}l{/Control}");
const field = within(dialog).getByRole("textbox", { name: "Type a path (Ctrl+L)" });
await userEvent.clear(field);
await userEvent.type(field, "/home/u/proj/web{Enter}");
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false, false)
);
});
it("New folder makes one in the current folder and selects it", async () => {
vi.mocked(api.lanes.mkdir).mockImplementation(async (parent, name) => {
TREE[parent] = [...(TREE[parent] ?? []), name];
return { path: `${parent}/${name}` };
});
const dialog = await open();
await option(dialog, "proj");
await userEvent.click(within(dialog).getByRole("button", { name: /New folder/ }));
await userEvent.type(
within(dialog).getByRole("textbox", { name: "Folder name" }),
"fresh{Enter}"
);
expect(api.lanes.mkdir).toHaveBeenCalledWith("/home/u", "fresh");
await waitFor(async () =>
expect(await option(dialog, "fresh")).toHaveAttribute("aria-selected", "true")
);
TREE["/home/u"] = ["proj", "notes"];
});
it("Show hidden re-lists with dot entries, and a pick is kept as Recent", async () => {
const onSelect = vi.fn();
const dialog = await open(onSelect);
await option(dialog, "proj");
expect(within(dialog).queryByRole("option", { name: /\.cfg/ })).toBeNull();
await userEvent.click(within(dialog).getByRole("checkbox", { name: /Show hidden/ }));
await option(dialog, "\\.cfg");
await userEvent.dblClick(await option(dialog, "proj"));
await option(dialog, "web");
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
expect(JSON.parse(localStorage.getItem("agenthub.browse.recent") || "[]")).toEqual([
"/home/u/proj",
]);
});
});
@@ -15,7 +15,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import { render, screen, waitFor, act, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
@@ -24,6 +24,12 @@ import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
// Local mode unless a test says otherwise.
const perms = vi.hoisted(() => ({ local: true }));
vi.mock("../../AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
});
vi.mock("../DiffEditor", () => ({
default: (p: {
value: string;
@@ -75,6 +81,9 @@ vi.mock("../../../lib/api", () => ({
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
gitBlame: vi.fn(),
gitDownloadUrl: (id: number, path: string) =>
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
gitUpload: vi.fn(),
},
},
}));
@@ -143,6 +152,7 @@ function discardOf(path: string) {
}
beforeEach(() => {
perms.local = true;
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -307,16 +317,29 @@ describe("LaneChangesPanel", () => {
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
});
it("says so, quietly, when the lane's directory is not a git repo", async () => {
it("says so, quietly, beside the tree at full width, when the folder is not a repo", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<LaneChangesPanel laneId={7} full />);
expect(
await screen.findByText("This workspace's directory is not a readable git repository.")
).toBeInTheDocument();
});
it("opens on Files, its only tab, when the workspace folder is not a repo", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<LaneChangesPanel laneId={7} />);
expect(
await screen.findByText("This workspace's directory is not a readable git repository.")
).toBeInTheDocument();
expect(await screen.findByTestId("lane-changes-browse")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Files" })).toHaveAttribute("aria-pressed", "true");
for (const name of ["Changes", "Review", "History"])
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
});
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
@@ -491,7 +514,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
it("opens a whole markdown file rendered, as source after Raw, and rendered again after Preview", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -511,12 +534,13 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-editor");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("shows an image file as the picture, with no edit control", async () => {
@@ -545,6 +569,94 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("reads a folder again on every open, so files added since show (not a stale empty level)", async () => {
let filled = false;
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "src"
? filled
? [{ name: "app.ts", path: "src/app.ts", dir: false }]
: []
: [{ name: "src", path: "src", dir: true }],
truncated: false,
}));
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("src"));
await waitFor(() => expect(api.lanes.gitTree).toHaveBeenCalledWith(7, "src"));
expect(within(sidebar).queryByText("app.ts")).toBeNull();
// Source arrives from outside the panel (the File Explorer, a terminal).
filled = true;
await user.click(within(sidebar).getByText("src")); // close
await user.click(within(sidebar).getByText("src")); // open again → read again
expect(await within(sidebar).findByText("app.ts")).toBeInTheDocument();
// And the header's File Explorer reports a change: open folders re-read.
filled = false;
rerender(<LaneChangesPanel laneId={7} full filesChanged={1} />);
await waitFor(() => expect(within(sidebar).queryByText("app.ts")).toBeNull());
});
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
perms.local = false;
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "out"
? [{ name: "report.md", path: "out/report.md", dir: false }]
: [{ name: "out", path: "out", dir: true }],
truncated: false,
}));
vi.mocked(api.lanes.gitUpload).mockResolvedValue({ path: "out/data.csv" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
const input = within(sidebar).getByTestId("lane-changes-upload-input") as HTMLInputElement;
const picked = vi.spyOn(input, "click").mockImplementation(() => {});
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
await user.click(screen.getByTestId("lane-changes-menu-upload"));
expect(picked).toHaveBeenCalled();
const file = new File(["a,b\n"], "data.csv", { type: "text/csv" });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(api.lanes.gitUpload).toHaveBeenCalledWith(7, "out", file));
// The folder it landed in is re-read and opened.
expect(await within(sidebar).findByText("report.md")).toBeInTheDocument();
const clicked: string[] = [];
const anchor = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clicked.push(this.getAttribute("href") ?? "");
});
await user.pointer({ keys: "[MouseRight]", target: within(sidebar).getByText("report.md") });
await user.click(screen.getByTestId("lane-changes-menu-download"));
expect(clicked).toEqual(["/api/lanes/7/git/download?path=out%2Freport.md"]);
anchor.mockRestore();
picked.mockRestore();
});
it("offers no upload, download or File Explorer in local mode", async () => {
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "out"
? [{ name: "report.md", path: "out/report.md", dir: false }]
: [{ name: "out", path: "out", dir: true }],
truncated: false,
}));
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
expect(screen.getByTestId("lane-changes-tree-menu")).toBeInTheDocument();
expect(screen.queryByTestId("lane-changes-menu-upload")).toBeNull();
expect(screen.queryByTestId("lane-changes-menu-explorer")).toBeNull();
});
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -659,6 +771,24 @@ describe("LaneChangesPanel", () => {
});
});
it("discards every file at once only after the dialog is confirmed", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
confirm: true,
})
);
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -951,6 +1081,20 @@ describe("LaneChangesPanel", () => {
);
});
it("does not offer Undo for a top commit its upstream already holds", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
await waitFor(() => expect(undo).toBeDisabled());
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
});
it("cherry-picks only from another branch's history", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts());
@@ -1687,6 +1831,24 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
});
it("opens a file from Files when the workspace folder is not a repo", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-browse");
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(
screen.queryByText("This workspace's directory is not a readable git repository.")
).toBeNull();
});
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn(), git: vi.fn() } },
}));
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("says there is nothing to push instead of asking to push zero commits", async () => {
const user = userEvent.setup();
vi.mocked(confirmDialog).mockClear();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 0 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("re-reads stale facts: a commit made since still gets the push confirm", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 1 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenLastCalledWith(
expect.objectContaining({ message: expect.stringContaining("Push 1 commit") })
)
);
});
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
const user = userEvent.setup();
render(
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
return params.get("run") ? null : (
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
);
}
const user = userEvent.setup();
render(
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
import {
DEFAULT_MODES,
readDefaultMode,
readDefaultTool,
SAFE_MODES,
writeDefaultMode,
writeDefaultTool,
type DefaultMode,
@@ -310,7 +310,7 @@ function AgentStep() {
writeDefaultMode(m);
setMode(m);
}}
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
/>
</div>
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
@@ -2,7 +2,7 @@
* @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only
* is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only
* for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
}));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -98,10 +98,12 @@ describe("account preferences", () => {
}
});
it("never keeps bypass as the starting permission mode", () => {
it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto");
writeDefaultMode("bypassPermissions" as never);
expect(readDefaultMode()).toBe("bypassPermissions");
expect(readDefaultMode({ safe: true })).toBe("auto");
expect(SAFE_MODES).not.toContain("bypassPermissions");
writeDefaultMode("nonsense" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan");
+34 -2
View File
@@ -1,7 +1,10 @@
/**
* @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach —
* files (the prompt box's own picker), Choose a file on this computer (a
* browser on the dashboard's own machine only: the OS's own picker,
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
* nothing uploaded), what is already there to attach —
* Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
@@ -21,6 +24,7 @@ import {
ChevronRight,
FileText,
FolderOpen,
HardDrive,
History,
Paperclip,
Plug,
@@ -29,7 +33,14 @@ import {
Settings2,
} from "lucide-react";
import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
import {
isLocalBrowser,
localAttachment,
projectAttachment,
recentUploads,
type Attachment,
} from "../../lib/promptAttach";
import { toast } from "../../lib/toast";
interface Entry {
name: string;
@@ -244,6 +255,17 @@ export function ChatAddMenu({
close();
onAttach([item]);
};
const pickLocal = async () => {
close();
// The dialog opens on the desktop, sometimes behind this window.
toast.info(t("chat.pickLocalOpen"));
try {
const r = await api.lanes.pickPath("file", cwd || undefined);
if (r.path) onAttach([localAttachment(r.path)]);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const manage = (tab: "skills" | "plugins") => {
close();
navigate(`/cc-config?tab=${tab}`);
@@ -291,6 +313,16 @@ export function ChatAddMenu({
onUpload();
}}
/>
{isLocalBrowser() && (
<Row
icon={<HardDrive className="h-4 w-4" />}
label={t("chat.pickLocal")}
title={t("chat.pickLocalHint")}
testId="chat-add-pick-local"
onHover={() => setSub(null)}
onClick={() => void pickLocal()}
/>
)}
<Row
icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")}
+315 -41
View File
@@ -8,23 +8,51 @@
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
* an attached image or text file through `GET /api/run/drop/:name`, an image
* the session read anywhere else through `GET /api/sessions/:id/image`. Anything
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
* text file it read, wrote or named (a generated report) anywhere else through
* `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
* `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
* or the project's); a built-in skill has no file and shows by name only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type DragEvent } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Copy, FileText, Loader2, X } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Copy,
Download,
FileText,
Loader2,
Maximize2,
Minimize2,
Share2,
X,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
import type { TranscriptMessage } from "../../lib/types";
import {
RENDERED,
artifactDocument,
artifactKind,
artifactLang,
artifactVersions,
frameSandbox,
parseCsv,
type ArtifactKind,
} from "../../lib/artifactView";
/** Where a Chat pane's files can be read from. */
export interface PreviewScope {
@@ -106,6 +134,14 @@ function useContent(path: string, scope: PreviewScope): Content {
// An image it read elsewhere (a screenshot under ~/.cache): served
// only because this session's own Read named it.
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
} else if (scope.sessionId && /^(?:\/|[A-Za-z]:\/)/.test(file)) {
// A text file it read or wrote elsewhere (a report under ~/Documents):
// served only because this session's own Read/Write/Edit named it.
show(
api.sessions
.readFile(scope.sessionId, path)
.then((f) => ({ kind: "text", text: f.text, truncated: f.truncated }))
);
} else setContent({ kind: "none" });
return () => {
live = false;
@@ -136,33 +172,96 @@ function useContent(path: string, scope: PreviewScope): Content {
return content;
}
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
const [head, ...body] = rows;
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<table className="w-full border-collapse text-xs">
{head && (
<thead className="sticky top-0 bg-surface-2">
<tr>
{head.map((h, i) => (
<th
key={i}
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
>
{h}
</th>
))}
</tr>
</thead>
)}
<tbody>
{body.map((r, i) => (
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
{r.map((c, j) => (
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
* scripts (none for an SVG) but never the dashboard's origin. */
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
const { theme } = useTheme();
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
return (
<iframe
data-testid="artifact-frame"
title={title}
srcDoc={doc ?? ""}
sandbox={frameSandbox(kind)}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
/>
);
}
type View = "preview" | "code";
/** A file's first tab: Preview where it renders — Markdown as Settings → Appearance says. */
function defaultView(path: string): View {
const kind = artifactKind(path);
if (!RENDERED.has(kind)) return "code";
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
}
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
const { t } = useTranslation("run");
const loaded = useContent(path, scope);
const kind = artifactKind(path);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null);
const content: Content =
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
if (content.kind === "loading") {
const shown: Content =
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
if (shown.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
if (content.kind === "image") {
if (shown.kind === "image") {
return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img
src={content.src}
src={shown.src}
alt={baseName(path)}
onError={() => setBroken(content.src)}
onError={() => setBroken(shown.src)}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
if (content.kind === "none") {
if (shown.kind === "none") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
@@ -171,69 +270,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
</div>
);
}
return (
<div className="min-h-0 flex-1 overflow-auto">
{isMarkdown(path) ? (
<MarkdownPreview text={content.text} className="p-4" />
) : (
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
{content.text}
</pre>
)}
{content.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
)}
</div>
const note = shown.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
);
if (view === "code" || !RENDERED.has(kind)) {
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
{note}
</div>
);
}
if (kind === "markdown") {
return (
<div className="min-h-0 flex-1 overflow-auto">
<MarkdownPreview text={shown.text} className="p-4" />
{note}
</div>
);
}
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
}
/** One file: header (name · EXT, Copy path, Close) over its content. */
const iconBtn =
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
/** Saves text (or an image's URL) as a file through the browser. */
function download(name: string, content: Content) {
const a = document.createElement("a");
if (content.kind === "image") a.href = content.src;
else if (content.kind === "text")
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
else return;
a.download = name;
a.click();
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
* versions the conversation made of it, Copy, Download, Share to the
* Artifacts Hub, full screen, Close. */
export function FilePreview({
path,
scope,
onClose,
className = "",
messages,
}: {
path: string;
scope: PreviewScope;
onClose: () => void;
className?: string;
/** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[];
}) {
const { t } = useTranslation("run");
return (
const kind = artifactKind(path);
const loaded = useContent(path, scope);
const [view, setView] = useState<View>(() => defaultView(path));
const [full, setFull] = useState(false);
const [sharing, setSharing] = useState(false);
const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []),
[messages, path]
);
// null: the newest — the file as it is now.
const [picked, setPicked] = useState<number | null>(null);
// Another file opens on its own default tab, at its newest version.
useEffect(() => {
setPicked(null);
setView(defaultView(path));
}, [path]);
const last = Math.max(versions.length - 1, 0);
const at = picked ?? last;
const older = picked !== null && picked < last ? versions[picked] : null;
const content: Content =
older && older.content !== null
? { kind: "text", text: older.content, truncated: false }
: loaded;
const known = (i: number) => i === last || versions[i]?.content != null;
const step = (dir: -1 | 1) => {
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
if (known(i)) return setPicked(i === last ? null : i);
}
};
const share = async () => {
if (sharing) return;
setSharing(true);
try {
const file = slashed(path);
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
await api.artifacts.pinFile({
laneId: scope.laneId,
path: file.slice(base.length + 1),
title: displayName(path),
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
});
} else if (content.kind === "text") {
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
title: displayName(path),
});
} else throw new Error(t("chat.artifact.cannotShare"));
toast.success(t("chat.artifact.shared"));
} catch (err) {
toast.error(err);
} finally {
setSharing(false);
}
};
const panel = (
<section
data-testid="chat-file-preview"
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
className={
full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
}
>
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="text-xs text-fg-muted">{extOf(path)}</span>
<div className="ml-auto flex items-center gap-1">
</button>
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
{versions.length > 1 && (
<span
data-testid="artifact-versions"
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
>
<button
type="button"
onClick={() => step(-1)}
disabled={![...Array(at).keys()].some(known)}
aria-label={t("chat.artifact.older")}
title={t("chat.artifact.older")}
className={iconBtn}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="tabular-nums">
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
</span>
<button
type="button"
onClick={() => step(1)}
disabled={at >= last}
aria-label={t("chat.artifact.newer")}
title={t("chat.artifact.newer")}
className={iconBtn}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</span>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
{RENDERED.has(kind) && kind !== "image" && (
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
{(["preview", "code"] as const).map((v) => (
<button
key={v}
type="button"
role="tab"
aria-selected={view === v}
data-testid={`artifact-tab-${v}`}
onClick={() => setView(v)}
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
>
{t(`chat.artifact.${v}`)}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
aria-label={t("chat.outline.copyPath", { path })}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
data-testid="artifact-copy"
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
title={t("chat.artifact.copy")}
aria-label={t("chat.artifact.copy")}
className={iconBtn}
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => download(displayName(path), content)}
disabled={content.kind !== "text" && content.kind !== "image"}
title={t("chat.artifact.download")}
aria-label={t("chat.artifact.download")}
className={iconBtn}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="artifact-share"
onClick={() => void share()}
disabled={sharing || content.kind === "loading" || content.kind === "none"}
title={t("chat.artifact.share")}
aria-label={t("chat.artifact.share")}
className={iconBtn}
>
{sharing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Share2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={onClose}
title={t("chat.outline.close")}
aria-label={t("chat.outline.close")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<Body path={path} scope={scope} />
<Body path={path} content={content} view={view} />
</section>
);
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
}
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
+21 -7
View File
@@ -16,7 +16,14 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
import {
useEffect,
useState,
type ComponentType,
type DragEvent,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
@@ -35,6 +42,8 @@ import {
X,
} from "lucide-react";
import { api, type SessionOutline } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { isShown } from "../../hooks/useOnScreen";
import { displayName, extOf } from "./ChatFilePreview";
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
@@ -49,25 +58,30 @@ const EMPTY: SessionOutline = {
mcp: [],
};
/** The session's outline, re-read on a visibility-gated poll. */
export function useSessionOutline(sessionId: string): SessionOutline {
/** The session's outline, re-read on a visibility-gated poll — skipped too
* while `onScreen` says the pane is hidden (another workspace or tab, kept
* mounted). An unchanged answer keeps the old object: no re-render. */
export function useSessionOutline(
sessionId: string,
onScreen?: RefObject<boolean>
): SessionOutline {
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
useEffect(() => {
let live = true;
const load = () =>
api.sessions
.outline(sessionId)
.then((o) => live && setOutline({ ...EMPTY, ...o }))
.then((o) => live && setOutline(keepIfSame<SessionOutline>({ ...EMPTY, ...o })))
.catch(() => {});
void load();
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
}, [sessionId, onScreen]);
return outline;
}
@@ -271,7 +285,7 @@ export function ChatOutline({
</span>
) : (
<span
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse border-accent" : "border-border"}`}
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse-dot border-accent" : "border-border"}`}
/>
)}
<span

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