Compare commits

...

47 Commits

Author SHA1 Message Date
nntrivi2001 a58e95a957 chore: v2.8.7 — Git SSH keys in tabs, safer Artifacts preview and search
- Settings → Git → Your git identity: SSH keys are one tab per host kind
  (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the
  AWS tab when only an RSA key exists.
- Artifacts: a file type the sandboxed preview cannot show (Office, zip,
  PDF) offers Download only, never a blank Preview.
- Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE).
- Skill frontmatter rewrite replaces a key with its continuation lines and
  quotes values YAML could misread.
- Plugin marketplace.json is read with a 1 MB streaming cap.
- Artifact lists look each owner up once.
2026-10-15 17:17:47 +07:00
nntrivi2001 4d6067650a chore: v2.8.6 — Administration page (Users + Server tabs), right-click menus, several SSH keys per host 2026-10-15 16:45:24 +07:00
nntrivi2001 5bc3f7d34a Merge branch 'feat/admin-console': Administration page (Users + Server tabs) 2026-10-15 16:44:09 +07:00
nntrivi2001 b058179177 feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
2026-10-15 16:42:47 +07:00
nntrivi2001 b6b40b0c1b feat(admin): Administration page — Users tab + Server tab (hardware, per-user CPU/memory/home share)
/users becomes /admin (redirected), a tab shell over the existing Users view
and a new Server tab backed by GET /api/host (server/lib/host-stats.js):
hostname/OS/uptime, CPU and load, memory and swap, every real disk with
HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples —
attributed by the member's Linux uid (S3) or, for host processes, by the
inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host
in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the
Server tab.
2026-10-15 16:33:32 +07:00
nntrivi2001 758609775f chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's
  Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable
  one showing its error); a folder opens that user's Trash (path bar
  Trash › user, Up back to the folders). An admin may restore/purge in
  another user's Trash (audited); purging asks first. The merged "All users"
  list is gone from the UI.
- A home's Trash is its user's whole recycle bin (home store + host store,
  items tagged `store`).
- `agenthub server setup` installs one Linux user per account on Linux by
  default (`--no-system` opts out); install-system messages drop `sudo`.
- Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>.

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

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

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

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

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

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

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

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

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

Closes #83

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Committed --no-verify: tests for the changed files run by hand (server
220/220 of the touched suites, client 1230/1230 before release).
2026-10-10 15:10:13 +07:00
nntrivi2001 f0dc1445fc fix: v2.6.2 — Windows panes no longer read as dead (psmux ignores -F), Update now's setup works under cmd.exe
- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
  asks for. Read as names, every live pane looked gone and the heal beat
  closed its run (and its tab) within seconds while the session kept
  running. tmux.js (and the Update all helper) now take the text before the
  first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
  subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
  Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
  npm's output (the error, not the warnings), says when it hit the time
  limit, and gets 20 min instead of 5.
2026-10-10 12:01:01 +07:00
nntrivi2001 96e4d28f7c fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists.
  The server was forked from the dashboard's cwd (the plugin cache dir),
  outlives updates, and the old cache dir is pruned — after that every new
  pane started in a deleted directory, where `claude` died at once ("run not
  found") and a shell printed getcwd errors. tmux calls now run from `/`,
  and the pane's sh wrapper cds into its folder itself, which also heals a
  tmux server that is already stuck.
- Changes panel: a workspace's extra folders are sections stacked under the
  workspace folder (VS Code Source Control style), with Close repository /
  Close other repositories on a header's right-click.
- Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]`
  line folds into its turn; uploads under `<data dir>/drops/` are listed.
- Update check: a checkout holding commits a newer release lacks reports
  `diverged_from_release` instead of offering a fast-forward that can only fail.
2026-10-09 17:24:14 +07:00
343 changed files with 42462 additions and 2954 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
}
]
}
+2 -2
View File
@@ -23,8 +23,8 @@ make while you work here as Codex.
- `scripts/` for hook/install/import/cleanup utilities.
## Validation expectations
- Backend changes: run `npm run test:server` when possible.
- Frontend changes: run `npm run test:client` when possible.
- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
- Run a full suite (`npm run test:server` / `npm run test:client`) only when genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib), a shared server core (`server/db.js`, `server/index.js`, auth/scope, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks. The pre-commit hook already picks related vs. full tests per commit, so do not add an extra full run before committing.
- If any check is skipped, report it explicitly.
## Safety expectations
+20 -15
View File
@@ -374,8 +374,8 @@ graph TD
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<tmpdir>/agenthub-drops-<uid>/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree`, `/:id/git/download` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/upload` (create-only, `lane-file-transfer.js`) and `POST /:id/git/:action` are same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
@@ -385,7 +385,8 @@ graph TD
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
@@ -757,6 +758,7 @@ erDiagram
TEXT metadata "JSON blob"
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
TEXT pinned_at "ISO 8601 or NULL — pinned to the top of its workspace's sidebar list"
TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
}
@@ -2244,6 +2246,7 @@ interface UpdateStatusPayload {
situation?: // categorical hint for the UI
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
| "release_available" // fast-forward onto the release tag
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
@@ -2309,6 +2312,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch <remote> --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep <tag>` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
### Why Self-Apply Stays Narrow
@@ -2321,7 +2325,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
---
@@ -2352,7 +2356,7 @@ graph TD
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
BUBBLE["SpeechBubble.tsx<br/>bubble"]
PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
CSS["sagi.css<br/>keyframes + per-mood expressions"]
end
@@ -2399,16 +2403,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
| Component | Responsibility |
| --- | --- |
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`, which also returns the `subject` session a pulse is about — name, cwd, lane) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. An error alerts on the transition INTO `error` only, and **Clear alerts** (`clearErrors`) moves the errored sessions into `dismissed`: their later updates, agent errors and failure events stay silent until the session goes active again or ends. `attentionOf` lists errored then waiting sessions by name; `MOMENT_OF_PULSE` maps each pulse to a pet animation. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
| **`quips.ts`** | Pure mood → phrase pools. |
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
### Touchpoints Outside the Folder
@@ -2420,7 +2425,7 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. |
| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. |
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
| `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. |
---
+13 -9
View File
File diff suppressed because one or more lines are too long
+70 -12
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`);
@@ -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;
@@ -4232,7 +4286,7 @@ 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"],
pool
.poolStatus()
.map((a) => [
@@ -4241,6 +4295,8 @@ 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(" ")
: "–",
@@ -4281,7 +4337,7 @@ 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"],
pool
.poolStatus()
.map((a) => [
@@ -4289,6 +4345,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
+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" />
-42
View File
@@ -1204,9 +1204,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1362,9 +1359,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1379,9 +1373,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1396,9 +1387,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1413,9 +1401,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1430,9 +1415,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1447,9 +1429,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1464,9 +1443,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1481,9 +1457,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1498,9 +1471,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1515,9 +1485,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1532,9 +1499,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1549,9 +1513,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1566,9 +1527,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+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={
+29 -4
View File
@@ -6,7 +6,8 @@
* 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`).
@@ -16,6 +17,7 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
@@ -30,9 +32,17 @@ import { applySystemTheme } from "../hooks/useTheme";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import { hydrate } from "../lib/userPrefs";
type AuthValue = { state: AuthState; logout: () => Promise<void> };
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 +63,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 +148,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 +165,9 @@ 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>;
return (
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
);
}
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
+3
View File
@@ -51,6 +51,7 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
* - `./Sagi/SagiMount`
*
* ## Public surface
@@ -81,6 +82,7 @@ import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
@@ -173,6 +175,7 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
</a>
<UpdateNotifier />
<InterruptedSessionsPrompt />
<SagiMount />
<OnboardingWizard />
<OnboardingTour />
+9 -21
View File
@@ -6,36 +6,29 @@
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* with no desktop or no zenity/kdialog) it falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure) — for a file field
* too, which then lists files and picks one on click.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
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);
@@ -47,15 +40,10 @@ export function PathPickButton({
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} catch {
// The in-app browser is the fallback whatever went wrong with the
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
setFallback(true);
} finally {
setBusy(false);
}
@@ -64,7 +52,6 @@ export function PathPickButton({
return (
<>
<button
ref={buttonRef}
type="button"
onClick={pick}
disabled={busy}
@@ -80,6 +67,7 @@ export function PathPickButton({
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
kind={kind}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
+541
View File
@@ -0,0 +1,541 @@
/**
* @file PetChat.tsx
* @description The pet's chat window. A question its Ask box cannot answer
* locally opens here instead of leaving the page: the first message starts a
* 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; **New chat**
* forgets it (the session stays in the workspace), **Open in workspace**
* opens its pane there. Opening never resumes an ended session: once the pane
* is gone, the next message starts a new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { api } from "../../lib/api";
import type { TranscriptMessage } from "../../lib/types";
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { isSubmitEnter } from "../../lib/keys";
import { 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(" · ");
}
interface PetChatProps {
/** The pet's shown name, for the header and greeting. */
name: string;
/** A question to send as soon as the chat opens (the Ask box's handoff). */
initialPrompt?: string | null;
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
onPromptTaken?: () => void;
onBack: () => void;
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,
initialPrompt,
onPromptTaken,
onBack,
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);
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 = 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);
setPending(prompt);
if (chat) {
// Typed into the attached pane; it may still be attaching.
for (let i = 0; i < SEND_TRIES; i++) {
if (submitToRun(chat.runId, prompt)) return;
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
}
setPending(null);
setDraft(prompt);
setError(t("sagi.chat.notSent"));
return;
}
const tool = petChatTool() ?? "claude";
setStarting(true);
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
setPending(null);
setDraft(prompt);
setError(
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setStarting(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t]
);
// The Ask box's question, sent once.
const taken = useRef(false);
useEffect(() => {
if (!initialPrompt || taken.current) return;
taken.current = true;
onPromptTaken?.();
void send(initialPrompt);
}, [initialPrompt, onPromptTaken, send]);
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">
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
<ArrowLeft size={16} />
</IconButton>
<div className="min-w-0 flex-1 px-1">
<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>
{chat && (
<IconButton
label={t("sagi.chat.openWorkspace")}
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
>
<ExternalLink size={15} />
</IconButton>
)}
<IconButton
label={t("sagi.chat.new")}
onClick={() => remember(null)}
disabled={!chat || starting}
>
<SquarePen size={15} />
</IconButton>
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
<X size={16} />
</IconButton>
</div>
{/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
<span className="text-3xl" aria-hidden>
🐾
</span>
<p className="text-sm font-medium text-fg-primary">
{t("sagi.chat.emptyTitle", { name })}
</p>
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
</div>
)}
{lines.map((l) =>
l.role === "tools" ? (
<div key={l.key} className="flex justify-start">
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
<Wrench size={10} className="shrink-0" aria-hidden />
<span className="truncate">{toolSummary(l.names)}</span>
</span>
</div>
) : l.role === "user" ? (
<div key={l.key} className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
{l.text}
</div>
</div>
) : (
<div key={l.key} className="flex justify-start">
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
<MarkdownContent text={l.text} dense prose />
</div>
</div>
)
)}
{pending && (
<div className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
{pending}
</div>
</div>
)}
{busy && !menu && (
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
</div>
</div>
)}
{starting && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
<Loader2 size={12} className="animate-spin" aria-hidden />
{t("sagi.chat.starting", { tool: toolLabel })}
</p>
)}
{chat && !sessionId && !starting && (
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
)}
</div>
{error && (
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
{/* prompt, or the question the CLI asks */}
<div className="pet-chat-foot px-2.5 py-2.5">
{menu && chat ? (
<div className="max-h-56 overflow-y-auto">
<PaneMenuCard
menu={menu}
onChoose={(i) => {
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onAnswer={(text) => {
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
/>
</div>
) : (
<form onSubmit={submit} className="flex items-end gap-1.5">
<textarea
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.chat.placeholder", { name })}
aria-label={t("sagi.chat.placeholder", { name })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
// Esc leaves the chat for the panel, not the whole mascot.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onBack();
}
}}
/>
<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 }
+200 -28
View File
@@ -77,6 +77,7 @@ import {
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
@@ -87,8 +88,19 @@ 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 { PetChat, petChatTool } 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
@@ -137,15 +149,21 @@ export function usePrefersReducedMotion(): boolean {
* 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" });
@@ -158,6 +176,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));
@@ -170,7 +200,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();
@@ -208,13 +238,19 @@ 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());
const [open, setOpen] = useState(false);
// The chat window (PetChat.tsx), and the Ask question it should send.
const [chatOpen, setChatOpen] = useState(false);
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const href = useSettingsHref();
const brain = useSagiBrain();
const place = useSagiPosition(size);
@@ -224,11 +260,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(() => {
@@ -239,6 +296,7 @@ function SagiBody() {
setOpen((v) => !v);
} else if (e.key === "Escape") {
setOpen(false);
setChatOpen(false);
}
};
window.addEventListener("keydown", onKey);
@@ -279,27 +337,53 @@ function SagiBody() {
[navigate]
);
// 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);
setChatOpen(false);
};
api.run
.list()
.then((r) => go(r.items))
.catch(() => go());
},
[brain, 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.
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
if (result.kind === "answer") {
if (result.moment) brain.playMoment(result.moment);
return result.text;
}
// Handoff: a question the pet cannot answer goes to the chat window,
// which starts a session of the default AI tool and shows it as chat.
if (petChatTool()) {
setChatPrompt(result.prompt);
setOpen(false);
setChatOpen(true);
return null;
}
// 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,9 +394,32 @@ function SagiBody() {
})();
return null;
},
[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,
@@ -329,6 +436,13 @@ function SagiBody() {
{!place.dragging && open && (
<SagiFlyout anchor={anchor}>
<SagiPanel
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
onPat={brain.pat}
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
@@ -340,6 +454,29 @@ function SagiBody() {
</SagiFlyout>
)}
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1), and kept mounted: unmounting would detach its terminal and
lose a half-typed message. */}
{chatOpen && (
<SagiFlyout anchor={anchor} beside>
<PetChat
name={name}
initialPrompt={chatPrompt}
onPromptTaken={() => setChatPrompt(null)}
onBack={() => {
setChatOpen(false);
setOpen(true);
}}
onClose={() => setChatOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setChatOpen(false);
}}
onBusy={onBusy}
/>
</SagiFlyout>
)}
{!place.dragging && menu && (
<SagiFlyout anchor={anchor} front>
<PetMenu
@@ -348,15 +485,19 @@ function SagiBody() {
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/appearance#pet");
navigate(href("appearance#pet"));
}}
/>
</SagiFlyout>
)}
{!open && !menu && brain.bubble && (
{!open && !menu && !chatOpen && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
<SpeechBubble
bubble={brain.bubble}
onDismiss={brain.dismissBubble}
onOpen={openSession}
/>
</SagiFlyout>
)}
@@ -368,34 +509,47 @@ 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={() => {
// A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar.
if (place.consumeDrag()) return;
setOpen((v) => !v);
if (chatOpen) setChatOpen(false);
else setOpen((v) => !v);
}}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
setChatOpen(false);
setMenu(true);
}}
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
aria-expanded={open}
title={t("sagi.avatarTitle")}
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open || chatOpen}
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}
@@ -403,10 +557,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}
+134 -37
View File
@@ -50,9 +50,6 @@
import { useState, type FormEvent, type ReactNode } from "react";
import {
Play,
Activity,
LayoutList,
Bell,
BellOff,
Trash2,
@@ -61,12 +58,29 @@ import {
AlertTriangle,
Hourglass,
Radio,
Heart,
ChevronRight,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SagiStatus } from "./brain";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4;
interface SagiPanelProps {
/** The name the pet goes by now. */
name: string;
/** The pet's own name, which an empty nickname falls back to. */
ownName: string;
/** The user's nickname for the pet, "" when none. */
nickname: string;
onRename: (name: string) => void;
onPat: () => void;
/** Errored, then waiting sessions, named. */
attention: ReadonlyArray<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
@@ -78,6 +92,13 @@ interface SagiPanelProps {
}
export function SagiPanel({
name,
ownName,
nickname,
onRename,
onPat,
attention,
onOpenSession,
status,
muted,
onToggleMute,
@@ -89,6 +110,13 @@ export function SagiPanel({
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const saveName = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
@@ -101,7 +129,7 @@ export function SagiPanel({
<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")}
aria-label={t("sagi.panel.label", { name })}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
@@ -109,7 +137,40 @@ export function SagiPanel({
<span className="text-base leading-none" aria-hidden>
🐾
</span>
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
{renaming === null ? (
<button
type="button"
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => setRenaming(e.target.value)}
onBlur={saveName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveName();
} else if (e.key === "Escape") {
// Esc cancels the rename only, not the whole panel.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setRenaming(null);
}
}}
/>
)}
<span
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
@@ -126,13 +187,23 @@ export function SagiPanel({
{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 className="flex shrink-0 items-center">
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={onPat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
>
<X size={15} />
</button>
</div>
</div>
<div className="p-3">
@@ -158,29 +229,55 @@ export function SagiPanel({
/>
</div>
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-3">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton
icon={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")}
@@ -198,10 +295,10 @@ export function SagiPanel({
<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")}
placeholder={t("sagi.panel.askPlaceholder", { name })}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t("sagi.panel.ask")}
aria-label={t("sagi.panel.ask", { name })}
/>
<button
type="submit"
@@ -284,7 +381,7 @@ function ActionButton({
}) {
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"
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}
>
+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" })
);
});
@@ -88,6 +88,93 @@ describe("Sagi widget", () => {
expect(within(btn).getByText("1")).toBeInTheDocument();
});
it("names the errored session in its bubble and in the panel", () => {
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: /sagi companion/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 panel header", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
});
it("hands a question it cannot answer to the chat window", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const input = screen.getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
});
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
const input = screen.getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
});
it("a pat shows its reaction on the pet even while the panel 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 panel status", () => {
renderSagi();
act(() => {
@@ -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,
+78 -25
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -49,12 +49,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { NavLink } from "react-router-dom";
import { NavLink, useLocation } from "react-router-dom";
import { settingsHref } from "../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { useDialog } from "../hooks/useDialog";
import {
LayoutDashboard,
FolderOpen,
HardDrive,
Gem,
BarChart3,
Settings,
Wifi,
@@ -70,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";
@@ -87,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";
@@ -186,8 +194,15 @@ export function Sidebar({
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
// link's own match (the path) cannot tell when it is the one on screen.
const location = useLocation();
const settingsOn =
location.pathname.startsWith("/settings") ||
new URLSearchParams(location.search).has("settings");
// Checking for (and applying) an update acts on the host: an admin's.
const 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);
@@ -462,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);
@@ -525,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
@@ -533,29 +602,13 @@ export function Sidebar({
}`}
>
<NavLink
to="/settings"
to={settingsHref("", location)}
title={collapsed ? t("nav:settings") : undefined}
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</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}
+5 -84
View File
@@ -1,7 +1,7 @@
/**
* @file SplashScreen.tsx
* @description Branding splash shown once per browser session on app load. A
* dark-tech "constellation" overlay built around the node-graph brand mark:
* dark-tech "constellation" overlay built around the SmartGift brand mark:
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
* in a staggered cascade. The tagline and the subtext pair are picked at
* random (per mount) from localized pools in `splash.json`, so the copy is
@@ -143,7 +143,7 @@ export function SplashScreen() {
<div className="splash-grain" aria-hidden="true" />
<div className="splash-content">
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
{/* Brand mark - the SmartGift logo */}
<div className="splash-mark" aria-hidden="true">
<span className="splash-mark-glow" />
<BrandMark />
@@ -167,74 +167,10 @@ export function SplashScreen() {
);
}
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
* with animated connector lines and pulsing outer nodes. */
/** The SmartGift mark — the same file as the favicon, local so the splash
* needs no network. */
function BrandMark() {
return (
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
<defs>
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#818cf8" />
</linearGradient>
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
</linearGradient>
</defs>
<polygon
className="splash-hex"
points="16,2 28,9 28,23 16,30 4,23 4,9"
fill="url(#splashBg)"
/>
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
<line
className="splash-line"
x1="16"
y1="13"
x2="16"
y2="7"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-2"
x1="18.6"
y1="17.5"
x2="24"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-3"
x1="13.4"
y1="17.5"
x2="8"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
<circle
className="splash-node splash-node-2"
cx="24.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
<circle
className="splash-node splash-node-3"
cx="7.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
</svg>
);
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
}
/** Faint background constellation: a handful of nodes joined by thin lines that
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
animation: splashGlowPulse 2.6s ease-in-out infinite;
}
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
.splash-line-2 { animation-delay: 0.62s; }
.splash-line-3 { animation-delay: 0.74s; }
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
.splash-greeting {
display: flex;
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
.splash-star,
.splash-mark,
.splash-mark-glow,
.splash-hex,
.splash-line,
.splash-node,
.splash-dot,
.splash-greeting,
.splash-tagline,
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
opacity: 1 !important;
stroke-dashoffset: 0 !important;
transform: none !important;
}
}
+96 -16
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";
@@ -463,7 +530,10 @@ export function UpdateNotifier() {
) : null}
{error ? (
<p className="text-xs text-status-danger" role="alert">
<p
className="text-xs text-status-danger whitespace-pre-wrap break-words max-h-48 overflow-auto"
role="alert"
>
{error}
</p>
) : null}
@@ -490,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"
@@ -511,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}
@@ -1,21 +1,21 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* path fills the field, a Cancel changes nothing, and a folder or a file falls
* back to the in-app browser on ANY native-picker failure (a LAN browser's
* EREMOTE included), where a double-click on a file picks it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { PathPickButton } from "../PathPickButton";
vi.mock("../../lib/api", () => ({
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
}));
vi.mock("../../lib/toast", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/home/u/.ssh",
parent: "/home/u",
entries: [
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
],
});
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
await userEvent.click(screen.getByRole("button"));
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
expect(within(dialog).queryByText("Select this folder")).toBeNull();
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
expect(screen.queryByRole("dialog")).toBeNull();
expect(toast.info).not.toHaveBeenCalled();
});
});
@@ -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", () => {
@@ -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,928 @@
/**
* @file ArtifactAddPanels.tsx
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
* uploaded file (server/lib/artifacts.js):
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
* file that becomes SKILL.md, several files, a whole folder — picked or
* dropped — or a .zip/.skill; an upload holding several skills makes one
* artifact each), **Write** (name, description, instructions) and
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
* .claude/skills). The user names the skill; the name is previewed as the
* folder and `/command` it installs as (`skillSlug`, the server's rule).
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
* web link, `owner/repo` or a marketplace.json link), read its plugin list
* and tick the ones to add; or type `name@marketplace` by hand, which is
* also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const label = "block text-[11px] font-medium text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
export function skillSlug(text: string): string {
return text
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
/** `field:` of a Markdown file's frontmatter, or "". */
export function frontmatterField(text: string, name: string): string {
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
type Picked = { file: File; path: string };
/** A small file's text ("" on failure), only to prefill the name/description. */
function readText(file: File): Promise<string> {
if (file.size > 1024 * 1024) return Promise.resolve("");
return new Promise((ok) => {
const r = new FileReader();
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
r.onerror = () => ok("");
r.readAsText(file);
});
}
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
const items = Array.from(e.dataTransfer.items || []);
const entries = items
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
.filter((x): x is FileSystemEntry => !!x);
if (!entries.length) {
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
}
const out: Picked[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (entry.isFile) {
const file = await new Promise<File>((ok, fail) =>
(entry as FileSystemFileEntry).file(ok, fail)
);
out.push({ file, path: prefix + entry.name });
return;
}
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in batches until an empty one.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
reader.readEntries(ok, fail)
);
if (!batch.length) break;
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
}
};
for (const entry of entries) await walk(entry, "");
return out;
}
/** What an upload will make, guessed the way the server decides it. */
function describeUpload(files: Picked[]): {
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
count: number;
folder: string;
} {
const real = files.filter((f) => !JUNK.test(f.path));
if (!real.length) return { kind: "none", count: 0, folder: "" };
if (real.length === 1 && isArchive(real[0]!.path))
return { kind: "archive", count: 0, folder: "" };
const roots = [
...new Set(
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
),
].sort((a, b) => a.length - b.length);
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
if (!top.length) {
return real.length === 1 && isText(real[0]!.path)
? { kind: "single-md", count: 1, folder: "" }
: { kind: "no-skill-md", count: 0, folder: "" };
}
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
return { kind: "many", count: top.length, folder: "" };
}
function Tabs<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { id: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
{options.map((o) => (
<button
key={o.id}
type="button"
role="tab"
aria-selected={value === o.id}
onClick={() => onChange(o.id)}
className={`flex-1 rounded px-2 py-1 text-xs ${
value === o.id
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
value,
onChange,
disabled,
required,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
required?: boolean;
}) {
const { t } = useTranslation("artifacts");
const slug = skillSlug(value);
return (
<div className="space-y-0.5">
<label className={label}>
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
value={value}
disabled={disabled}
placeholder="release-notes"
onChange={(e) => onChange(e.target.value)}
/>
</label>
{value.trim() && (
<p className={hint} data-testid="skill-slug">
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
</p>
)}
</div>
);
}
// ── Skill ─────────────────────────────────────────────────────────────────
export function SkillAddPanel({
lanes,
onAdded,
onClose,
}: {
lanes: Lane[];
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation("artifacts");
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
const [busy, setBusy] = useState(false);
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [description, setDescription] = useState("");
const [descTouched, setDescTouched] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [files, setFiles] = useState<Picked[]>([]);
const [dragging, setDragging] = useState(false);
const [laneId, setLaneId] = useState<number | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
// `webkitdirectory` is not in React's typings.
useEffect(() => {
folderInput.current?.setAttribute("webkitdirectory", "");
}, [mode]);
useEffect(() => {
if (mode !== "existing") return;
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}, [mode, laneId]);
const shape = useMemo(() => describeUpload(files), [files]);
// Fill the name and description from what was picked, unless typed.
useEffect(() => {
if (!files.length) return;
const md =
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
let live = true;
void (async () => {
const text = md ? await readText(md.file) : "";
if (!live) return;
const fmName = frontmatterField(text, "name");
const fmDesc = frontmatterField(text, "description");
if (!nameTouched) {
const guess =
shape.kind === "single-md"
? fmName || stem(md?.path ?? "")
: shape.kind === "one"
? fmName || shape.folder
: shape.kind === "archive"
? stem(files[0]!.path)
: "";
setName(guess);
}
if (!descTouched && fmDesc) setDescription(fmDesc);
})();
return () => {
live = false;
};
// Refill only when the pick changes, never on a typed name.
}, [files, shape]);
const pick = (list: FileList | null) => {
if (!list?.length) return;
setFiles(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
}))
);
};
const run = async (fn: () => Promise<number>) => {
setBusy(true);
try {
const n = await fn();
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const many = shape.kind === "many";
const total = files.reduce((n, f) => n + f.file.size, 0);
const meta = (
<>
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
disabled={many}
placeholder={t("skill.descriptionHint")}
onChange={(e) => {
setDescTouched(true);
setDescription(e.target.value);
}}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
</>
);
return (
<div className="space-y-3">
<Tabs
value={mode}
onChange={setMode}
options={[
{ id: "upload", label: t("skill.tab.upload") },
{ id: "write", label: t("skill.tab.write") },
{ id: "existing", label: t("skill.tab.existing") },
]}
/>
{mode === "upload" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
const r = await api.artifacts.uploadSkills(files, {
name: many ? undefined : name.trim(),
description: many ? undefined : description.trim(),
title: many ? undefined : title.trim(),
});
return r.items.length;
});
}}
>
<div
data-testid="skill-drop"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void droppedFiles(e).then((list) => list.length && setFiles(list));
}}
className={`rounded-lg border border-dashed p-4 text-center ${
dragging ? "border-accent bg-accent/10" : "border-border"
}`}
>
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
<div className="mt-2 flex flex-wrap justify-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
onClick={() => fileInput.current?.click()}
>
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => folderInput.current?.click()}
>
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
</button>
</div>
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="skill-files"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
<input
ref={folderInput}
type="file"
multiple
hidden
data-testid="skill-folder"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
</div>
{files.length > 0 && (
<div className="rounded border border-border bg-surface-2 p-2">
<div className="mb-1 flex items-center gap-2">
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
</p>
<span className={hint}>
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
</span>
<button
type="button"
aria-label={t("skill.clear")}
className="text-fg-muted hover:text-fg-primary"
onClick={() => {
setFiles([]);
setNameTouched(false);
setDescTouched(false);
}}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
{files.slice(0, 200).map((f) => (
<li
key={f.path}
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
>
{f.path}
</li>
))}
</ul>
</div>
)}
{many ? (
<p className={hint}>{t("skill.manyHint")}</p>
) : (
<SkillNameField
value={name}
onChange={(v) => {
setNameTouched(true);
setName(v);
}}
required={shape.kind === "single-md"}
/>
)}
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!files.length ||
shape.kind === "no-skill-md" ||
(shape.kind === "single-md" && !skillSlug(name))
}
>
{busy ? t("skill.uploading") : t("form.save")}
</button>
</div>
</form>
)}
{mode === "write" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api.artifacts.writeSkill({
name: name.trim(),
description: description.trim(),
content,
title: title.trim() || undefined,
});
return 1;
});
}}
>
<SkillNameField value={name} onChange={setName} required />
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!skillSlug(name) ||
!content.trim() ||
(!description.trim() && !frontmatterField(content, "description"))
}
>
{t("form.save")}
</button>
</div>
</form>
)}
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("form.pickWorkspace")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(async () => {
await api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
});
return 1;
})
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
)}
</div>
);
}
// ── Plugin ────────────────────────────────────────────────────────────────
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
const { t } = useTranslation("artifacts");
const [link, setLink] = useState("");
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [have, setHave] = useState<Set<string>>(new Set());
const [manual, setManual] = useState(false);
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
api.artifacts
.list({ scope: "mine", kind: "plugin" })
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
.catch(() => setHave(new Set()));
}, []);
const read = async () => {
setBusy(true);
setError(null);
setInfo(null);
try {
const r = await api.artifacts.inspectMarket(link.trim());
setInfo(r);
setPicked(
new Set(
r.plugins
.filter((p) => !have.has(p.key))
.slice(0, 1)
.map((p) => p.key)
)
);
setMarket(r.market);
} catch (err) {
const e = err as Error & { code?: string };
setError(e.message);
if (MANUAL_CODES.has(e.code ?? "")) {
setManual(true);
setMarket(link.trim());
}
} finally {
setBusy(false);
}
};
const addPicked = async () => {
if (!info) return;
setBusy(true);
let n = 0;
try {
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
await api.artifacts.addPlugin({
key: p.key,
market: info.market,
title: p.name,
description: p.description,
});
n++;
}
toast.success(t("plugin.addedMany", { count: n }));
onAdded();
onClose();
} catch (err) {
toast.error(err);
if (n) onAdded();
} finally {
setBusy(false);
}
};
const toggle = (k: string) =>
setPicked((s) => {
const next = new Set(s);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
return (
<div className="space-y-3">
<form
className="space-y-1"
onSubmit={(e) => {
e.preventDefault();
if (link.trim()) void read();
}}
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<input
className={field}
value={link}
autoFocus
placeholder="https://git.example.com/org/claude-skills.git"
onChange={(e) => setLink(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !link.trim()}
>
{busy && !info ? t("plugin.reading") : t("plugin.read")}
</button>
</div>
</label>
<p className={hint}>{t("plugin.linkHint")}</p>
</form>
{error && (
<p
role="alert"
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
>
{error}
</p>
)}
{info && (
<div className="space-y-2" data-testid="market-plugins">
<div>
<p className="text-sm font-medium text-fg-primary">
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
{info.name}
</p>
{info.description && <p className={hint}>{info.description}</p>}
</div>
{info.plugins.length === 0 ? (
<p className={hint}>{t("plugin.none")}</p>
) : (
<>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={picked.size === info.plugins.length}
onChange={(e) =>
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
}
/>
{t("plugin.all")}
</label>
<ul className="max-h-64 space-y-1 overflow-auto">
{info.plugins.map((p) => (
<li key={p.key}>
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
className="mt-0.5"
checked={picked.has(p.key)}
onChange={() => toggle(p.key)}
/>
<span className="min-w-0 flex-1">
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
{p.version && (
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
)}
{have.has(p.key) && (
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{t("plugin.have")}
</span>
)}
{p.description && (
<span className="block text-[11px] text-fg-muted">{p.description}</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="flex justify-end">
<button
type="button"
className="btn-primary text-sm"
disabled={busy || picked.size === 0}
onClick={() => void addPicked()}
>
{t("plugin.addPicked", { count: picked.size })}
</button>
</div>
</>
)}
</div>
)}
<div className="border-t border-border-light pt-2">
<button
type="button"
className="text-xs text-accent hover:underline"
aria-expanded={manual}
onClick={() => setManual((v) => !v)}
>
{t("plugin.manual")}
</button>
{manual && (
<form
className="mt-2 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
try {
await api.artifacts.addPlugin({
key: key.trim(),
market: market.trim(),
title,
description,
});
toast.success(t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo · https://… · git@host:org/repo.git"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
</div>
</div>
);
}
// ── File upload ───────────────────────────────────────────────────────────
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
const { t } = useTranslation("artifacts");
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const tooBig = !!file && file.size > 25 * 1024 * 1024;
return (
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (!file) return;
setBusy(true);
try {
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
toast.success(t("added"));
setFile(null);
setTitle("");
onAdded();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<label className={label}>
{t("file.fromComputer")}
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
<div className="flex gap-2">
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !file || tooBig}
>
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
</button>
</div>
</form>
);
}
@@ -9,7 +9,11 @@
* the reader is there: anything that makes it taller — a new line, the live
* step, an image loading, the pane shown again after being hidden by CSS
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
* chat passes in whether the CLI waits on the user (`waiting`, with
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -57,13 +61,15 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef } from "react";
import { memo, useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import { MessageList, promptTokens } from "./MessageList";
import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -76,6 +82,10 @@ const POLL_INTERVAL_MS = 3000;
// Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000;
// A hook burst (Pre + Post per tool call, several sessions) refetches the tail
// at most this often: the first event still fetches at once, the last is never
// lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500;
interface ConversationViewProps {
sessionId: string;
@@ -89,18 +99,47 @@ interface ConversationViewProps {
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
/** The CLI is asking the user something (a permission prompt). */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
stopped?: number;
/** The newest reply's prompt size (input + cache) and model: how full the
* context is. */
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
}
export function ConversationView({
function ConversationViewPane({
sessionId,
initialTranscriptId,
fill = false,
onOpenFile,
live = false,
follow = 0,
waiting = false,
onOpenTerminal,
stopped = 0,
onContext,
onNotStarted,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -128,6 +167,10 @@ 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.
@@ -146,7 +189,7 @@ export function ConversationView({
}
loadTranscripts();
const interval = window.setInterval(() => {
if (document.visibilityState === "visible") loadTranscripts();
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
@@ -178,6 +221,7 @@ export function ConversationView({
limit: 50,
});
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setTotal(result.total);
setHasMore(result.has_more);
@@ -185,7 +229,11 @@ export function ConversationView({
firstLineRef.current = result.first_line;
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
// A live pane's new session has no row until its first hook: nothing
// to show yet, and the poll below picks it up once it exists.
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
setNotStarted(notYet);
setMessages([]);
setTotal(0);
} finally {
@@ -225,6 +273,7 @@ export function ConversationView({
limit: 50,
});
if (gen !== genRef.current) return; // another transcript is shown now
setNotStarted(false);
if (result.messages.length === 0) return;
lastLineRef.current = result.last_line;
@@ -260,24 +309,33 @@ 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]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
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;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchNewMessages();
fetchSoon();
});
return unsubscribe;
}, [sessionId, fetchNewMessages]);
}, [sessionId, fetchSoon, onScreen]);
onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages();
};
// Resync on WebSocket reconnect: events that landed during a transient
// disconnect are gone from the bus, but the JSONL still has them, so a
@@ -298,7 +356,7 @@ export function ConversationView({
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (document.visibilityState === "visible") fetchNewMessages();
if (isShown(onScreen)) fetchNewMessages();
}, POLL_INTERVAL_MS);
}
function stop() {
@@ -324,7 +382,7 @@ export function ConversationView({
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages]);
}, [fetchNewMessages, onScreen]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
@@ -491,7 +549,9 @@ export function ConversationView({
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
<div ref={contentRef}>
{/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
@@ -513,6 +573,22 @@ export function ConversationView({
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.notStartedBody")}
</p>
{onOpenTerminal && (
<button
type="button"
onClick={onOpenTerminal}
className="btn-secondary mt-3 text-xs"
>
{t("conversation.openTerminal")}
</button>
)}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
@@ -521,7 +597,15 @@ export function ConversationView({
</p>
</div>
) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
<MessageList
messages={messages}
loading={false}
onOpenFile={onOpenFile}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
/>
)}
</div>
</div>
@@ -562,3 +646,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,7 +4,14 @@
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs.
* and auto-linked URLs. A file path — absolute, or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
* inline code or as a markdown link's target — opens through `OpenFileContext`
* when the page provides one (the page resolves a relative one against its own
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
* of how serious it is.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
@@ -77,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
const QUOTE_RE = /^\s*>\s?(.*)$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
/** A GFM table starts at a line holding a pipe that is followed by a divider. */
function isTableStart(lines: string[], i: number): boolean {
return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!);
}
function splitTableRow(line: string): string[] {
// Trim leading/trailing pipes, then split, respecting escaped pipes.
@@ -164,7 +176,7 @@ function parseBlocks(src: string): Block[] {
}
// Table: header line followed by an alignment divider
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
if (isTableStart(lines, i)) {
const header = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1]!);
i += 2;
@@ -220,7 +232,8 @@ function parseBlocks(src: string): Block[] {
continue;
}
// Paragraph: collect until a blank line or the start of another block
// Paragraph: collect until a blank line or the start of another block —
// a table included, which (as in GFM) may follow prose with no blank line.
const paraLines: string[] = [line];
i++;
while (i < lines.length) {
@@ -232,7 +245,8 @@ function parseBlocks(src: string): Block[] {
HR_RE.test(nl) ||
UL_RE.test(nl) ||
OL_RE.test(nl) ||
QUOTE_RE.test(nl)
QUOTE_RE.test(nl) ||
isTableStart(lines, i)
) {
break;
}
@@ -244,6 +258,113 @@ function parseBlocks(src: string): Block[] {
return blocks;
}
/** Opens a file a message names; provided by a view that can show one. */
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
const LINK_CLASS =
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
// A relative path needs a folder AND an extension starting with a letter
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
const REL_PATH_RE =
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
/** A path at the start of `text`, absolute or relative, or null. */
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
// A markdown link target that is a page, not a file.
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
// A link starts a word: "and/or" or "a/b/c" stays text.
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
// "2fd0c903" stay text.
const NUM_RE =
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
const numStart = (text: string, i: number) =>
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
const LABEL_RE =
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
const LABEL_CLASS = (word: string) =>
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
? "text-status-danger"
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
? "text-status-warning"
: "text-accent";
function UrlLink({ url }: { url: string }) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{url}
</a>
);
}
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
function decodeHref(href: string): string {
let p = href
.replace(/^file:\/\//i, "")
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
.replace(/#.*$/, "");
try {
p = decodeURIComponent(p);
} catch {
/* a stray % stays as written */
}
return p;
}
/** A path as a button that opens it, or as it was when nothing can. */
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
const open = React.useContext(OpenFileContext);
if (!open) return <>{children}</>;
return (
<button
type="button"
title={path}
onClick={() => open(stripLine(path))}
className={`${LINK_CLASS} text-left`}
>
{children}
</button>
);
}
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
export function linkify(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let buf = "";
for (let i = 0; i < text.length;) {
const rest = text.slice(i);
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
const path = !url && atWordStart(text, i) && matchPath(rest);
const hit = url || path;
if (!hit) {
buf += text[i++];
continue;
}
if (buf) out.push(buf);
buf = "";
out.push(
url ? (
<UrlLink key={i} url={url} />
) : (
<PathLink key={i} path={hit}>
{hit}
</PathLink>
)
);
i += hit.length;
}
if (buf) out.push(buf);
return out;
}
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
@@ -267,11 +388,12 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Inline code: `...`
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
push(
const code = (
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
{codeM[1]}
</code>
);
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
i += codeM[0].length;
continue;
}
@@ -280,7 +402,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) {
push(
<strong className="font-semibold text-fg-primary">
<strong className="font-bold text-accent">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong>
);
@@ -315,9 +437,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Markdown link
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
if (linkM) {
const href = linkM[2]!;
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
if (
/^file:\/\//i.test(href) ||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
) {
push(
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
);
i += linkM[0].length;
continue;
}
push(
<a
href={linkM[2]!}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
@@ -330,22 +464,39 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
}
// Auto-link
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
const urlM = rest.match(URL_RE);
if (urlM) {
push(
<a
href={urlM[0]}
target="_blank"
rel="noopener noreferrer"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
>
{urlM[0]}
</a>
);
push(<UrlLink url={urlM[0]} />);
i += urlM[0].length;
continue;
}
// An absolute path at a word start
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
if (pathM) {
push(<PathLink path={pathM}>{pathM}</PathLink>);
i += pathM.length;
continue;
}
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
if (labelM) {
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
i += labelM[0].length;
continue;
}
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
if (numM) {
push(
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
{numM[0]}
</span>
);
i += numM[0].length;
continue;
}
buf += text[i]!;
i++;
}
@@ -6,14 +6,27 @@
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page. An image the prompt box
* attached reaches the transcript as its saved path; a user turn shows the
* image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
* image instead (`GET /api/run/drop/:name`) — any other attached file as a
* chip that opens its preview through `onOpenFile` —, and Claude Code's own
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
* a System row; URLs and absolute paths in a turn are links (a path opens
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
* like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`). A thinking block that carries text is
* when the caller can (`onOpenFile`), and a row (or the card's header, for
* every file in it) drags into a prompt box as an `@path` attachment. An
* AskUserQuestion is its own card (`AskCard`), not part of the collapsed work
* line: each question with the answer the user picked or typed. A thinking block that carries text is
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
* chat (`live`) ends, until its turn is over, with what the model is doing
* now — the call still running, else "Thinking…" — and for how long.
* now — the calls still running (several when it runs them in parallel),
* else "Thinking…", or the question the CLI waits on with a way to its
* terminal (`waiting`) — and, like the terminal's status line, how long the
* turn has run and the output tokens it has made; a call running long shows
* its own time, and a line silent for minutes turns amber. A turn that ended
* closes with how long it took and its output.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -62,7 +75,7 @@
*
* ----------------------------------------------------------------------------- */
import { memo, useEffect, useState, useMemo } from "react";
import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
@@ -81,6 +94,11 @@ import {
Cog,
FileText,
Loader2,
Check,
Hand,
SquareTerminal,
MessageCircleQuestion,
CornerDownRight,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -124,7 +142,9 @@ const SENDER_STYLES: Record<
},
};
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent";
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
@@ -135,6 +155,14 @@ interface MessageListProps {
onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
/** The CLI is asking the user something (a permission prompt): the live
* line says so instead of "Thinking…". */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the turn on screen is over, even when the
* transcript never says so. */
stopped?: number;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -151,9 +179,20 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
return map;
}
type ResultMap = ReturnType<typeof buildToolResultMap>;
/** The map is rebuilt on every tail append, but a block's own result keeps its
* identity (old messages are kept, not copied): comparing just those lets a
* memoized row skip the re-render when nothing it shows changed. */
const sameResults = (blocks: TranscriptContent[], a: ResultMap, b: ResultMap) =>
a === b || blocks.every((bl) => !bl.id || a.get(bl.id) === b.get(bl.id));
const sameBlocks = (a: TranscriptContent[], b: TranscriptContent[]) =>
a === b || (a.length === b.length && a.every((bl, i) => bl === b[i]));
/** The whole prompt a message was answered from: `input_tokens` is only its
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
@@ -200,12 +239,32 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
);
}
/** Compact pill for /command invocations parsed out of TUI markup. */
/** Typed text with its `/command` and `@path` tokens as chips, the rest linkified. */
function typedText(text: string): React.ReactNode[] {
return splitPrompt(text).map((tok, i) =>
tok.kind === "text" ? (
<Fragment key={i}>{linkify(tok.text)}</Fragment>
) : (
<span
key={i}
className={`rounded-md px-1.5 py-0.5 font-mono text-[0.92em] font-medium ${TOKEN_TONE[tok.kind]}`}
>
{tok.text}
</span>
)
);
}
/** Compact pill for /command invocations parsed out of TUI markup: the
* command in the prompt box's colour, its arguments after it. */
function CommandPill({ display }: { display: string }) {
const [name, ...args] = display.split(" ");
return (
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
<span className="text-status-success/70">›</span>
<span className="break-all">{display}</span>
<div className="inline-flex max-w-full items-baseline gap-2 rounded-lg border border-accent/25 bg-accent/10 px-2.5 py-1 font-mono text-sm">
<span className="font-semibold text-accent">{name}</span>
{args.length > 0 && (
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
)}
</div>
);
}
@@ -417,7 +476,9 @@ type Item =
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
| { kind: "ask"; key: string; block: TranscriptContent }
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
| { kind: "done"; key: string; ms: number; output: number };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
@@ -426,9 +487,20 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
let reply: TranscriptMessage | null = null;
let output = outputCounter();
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
}
reply = null;
output = outputCounter();
};
messages.forEach((msg, idx) => {
const key = keys[idx]!;
@@ -438,13 +510,38 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") {
endTurn(key);
start = msg.timestamp ?? null;
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
if (sender !== "assistant") {
// Claude Code's line for the image a prompt attached: the image joins that prompt.
const paths = sender === "system" ? imageSourcePaths(msg) : null;
const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
if (paths && prompt?.kind === "user") {
prompt.msg = {
...prompt.msg,
content: [
...prompt.msg.content.map((c) =>
c.type === "text" && c.text
? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
: c
),
{ type: "text", text: paths.join("\n") },
],
};
return;
}
return items.push({ kind: "row", key, msg });
}
reply = msg;
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (block.type === "tool_use") {
// A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
} else if (block.type === "tool_use") {
if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
@@ -458,7 +555,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
function EditedFiles({
function EditedFilesCard({
blocks,
toolResultMap,
onOpenFile,
@@ -499,7 +596,11 @@ function EditedFiles({
);
return (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
<div
draggable
onDragStart={(e) => dragAttachments(e, [...files.keys()].map(projectAttachment))}
className="flex cursor-grab items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary"
>
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
{counts(added, removed)}
@@ -511,6 +612,8 @@ function EditedFiles({
title={p}
disabled={!onOpenFile}
onClick={() => onOpenFile?.(p)}
draggable
onDragStart={(e) => dragAttachments(e, [projectAttachment(p)])}
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
>
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
@@ -522,15 +625,39 @@ function EditedFiles({
</div>
);
}
const EditedFiles = memo(
EditedFilesCard,
(p, n) =>
p.onOpenFile === n.onOpenFile &&
sameBlocks(p.blocks, n.blocks) &&
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
function MessageListView({
messages,
loading,
onOpenFile,
live = false,
waiting = false,
onOpenTerminal,
stopped = 0,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
);
const keys = useMemo(() => messageKeys(messages), [messages]);
const lastKey = keys[keys.length - 1] ?? null;
// The newest line when Stop was pressed. Esc before the model wrote anything
// puts the prompt back in Claude Code's input box and writes no
// "[Request interrupted…]" line (issue #74), so the transcript still ends on
// the prompt: no live line until a newer one arrives.
const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null });
if (stop.n !== stopped) setStop({ n: stopped, on: lastKey });
const halted = stop.on !== null && stop.on === lastKey;
const pending = useMemo(
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
[live, halted, messages, toolResultMap]
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
if (loading) {
@@ -546,40 +673,62 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
}
return (
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
return (
<SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
/>
);
}
})}
{pending && <RunningStep block={pending.block} since={pending.since} />}
</div>
<OpenFileContext.Provider value={onOpenFile}>
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
return (
<SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return (
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
);
case "ask":
return (
<AskCard
key={item.key}
block={item.block}
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
/>
);
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
/>
);
case "done":
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
}
})}
{pending && (
<RunningStep
{...pending}
start={stats?.start ?? null}
tokens={stats?.output ?? 0}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
/>
)}
</div>
</OpenFileContext.Provider>
);
}
@@ -587,77 +736,381 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
// written (a crash, a kill) — stop saying it works.
const IDLE_AFTER_MS = 15 * 60_000;
/** What the model is doing while its turn is not over: the call still
* waiting for its result (`block`), else thinking (`block: null`); `since`
* is that line's time. Null once the turn ended. */
/** What the model is doing while its turn is not over: the calls of the turn
* still waiting for their results (`blocks`, oldest first — parallel calls
* finish in any order), `since` the oldest one's time, `last` the newest
* line's; `blocks` is empty while it thinks. Null once the turn ended. */
export function workingOn(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null {
): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null {
const blocks: TranscriptContent[] = [];
let since: string | null = null;
let last: string | null = null;
// The newest line's verdict: undefined until it is found.
let alive: boolean | undefined;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
// A rename marker or a harness line says nothing about the turn.
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
const since = msg.timestamp ?? null;
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
const at = msg.timestamp ?? null;
const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
const newest = alive === undefined;
if (newest) last = at;
if (msg.type === "assistant") {
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
if (call?.id && !results.has(call.id)) return { block: call, since };
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
// "end_turn": over, whatever the transcript failed to pair.
if (newest && msg.stop_reason === "end_turn") return null;
for (const b of [...msg.content].reverse()) {
if (b.type === "tool_use" && b.id && !results.has(b.id)) {
blocks.unshift(b);
since = at;
}
}
// "tool_use": a call follows. None (an older server): over.
if (newest) alive = msg.stop_reason === "tool_use" && fresh;
continue;
}
// A prompt or a tool result: the model answers it next — unless the user stopped it.
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
if (text.startsWith("[Request interrupted")) return null;
return fresh ? { block: null, since } : null;
if (newest) alive = fresh;
// The prompt that opened the turn: nothing before it is this turn's.
if (text) break;
}
return null;
if (blocks.length) return { blocks, since, last };
return alive ? { blocks, since: null, last } : null;
}
/** Claude Desktop's live line: the step running now and for how long. */
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
/** Output tokens counted once per API call: Claude Code writes one line per
* content block, each repeating its call's usage — consecutive lines with
* the same prompt size are one call. */
function outputCounter() {
let total = 0;
let call: string | null = null;
let callOutput = 0;
return {
add(u: NonNullable<TranscriptMessage["usage"]>) {
const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`;
if (key !== call) {
total += callOutput;
call = key;
callOutput = 0;
}
callOutput = Math.max(callOutput, u.output_tokens || 0);
},
get total() {
return total + callOutput;
},
};
}
/** The live turn so far, as the terminal counts it: `start` is the prompt
* that opened it, `output` the output tokens of every API call since. */
export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } {
const output = outputCounter();
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
if (msg.type === "assistant") {
if (msg.usage) output.add(msg.usage);
continue;
}
const sender = msg.sender ?? "user";
if (sender === "user" && msg.content.some((c) => c.type === "text")) {
return { start: msg.timestamp ?? null, output: output.total };
}
}
return { start: null, output: output.total };
}
// A call running this long shows its own time beside the turn's.
const STEP_SHOWN_MS = 30_000;
// No new line for this long: the line turns amber — the step may be stuck.
const QUIET_MS = 3 * 60_000;
/** A number that counts up to its new value instead of jumping (none when
* the user asked for reduced motion). */
function useCountUp(value: number): number {
const [shown, setShown] = useState(value);
const shownRef = useRef(value);
useEffect(() => {
const from = shownRef.current;
if (from === value) return;
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduce || typeof requestAnimationFrame === "undefined") {
shownRef.current = value;
setShown(value);
return;
}
const t0 = performance.now();
let raf = 0;
const step = (t: number) => {
const k = Math.min(1, (t - t0) / 600);
shownRef.current = Math.round(from + (value - from) * k);
setShown(shownRef.current);
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [value]);
return shown;
}
/** A call's one-line title: its description (Claude Code's Bash
* `description`), else the tool and its target. */
function stepTitle(block: TranscriptContent): string {
const input = block.input as Record<string, unknown> | undefined;
return typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
}
/** The terminal's live line: the step running now (or the question the CLI
* waits on), then the turn's time and output tokens so far. */
function RunningStep({
blocks,
since,
last,
start,
tokens,
waiting,
onOpenTerminal,
}: {
blocks: TranscriptContent[];
since: string | null;
last: string | null;
start: string | null;
tokens: number;
waiting: boolean;
onOpenTerminal?: () => void;
}) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
// no clock: skip the re-render until it is on screen again.
const timer = window.setInterval(() => {
const el = boxRef.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, []);
const input = block?.input as Record<string, unknown> | undefined;
const title = !block
? t("conversation.working")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
const counted = useCountUp(tokens);
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
const first = blocks[0];
const step = first ? age(since) : 0;
const quiet = !waiting && age(last) > QUIET_MS;
const title = waiting
? first
? t("conversation.waitingFor", { step: stepTitle(first) })
: t("conversation.waiting")
: first
? stepTitle(first)
: t("conversation.working");
const extra = [
start && formatMs(age(start)),
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
].filter(Boolean);
const amber = waiting || quiet;
return (
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
<span className="min-w-0 truncate">{title}</span>
{since && (
<span className="flex-shrink-0 font-mono text-xs">
{formatMs(now - parseDate(since).getTime())}
<div ref={boxRef} data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{waiting ? (
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
) : (
<Loader2
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
/>
)}
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
{title}
</span>
{blocks.length > 1 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
aria-expanded={showAll}
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
>
{t("conversation.moreSteps", { count: blocks.length - 1 })}
</button>
)}
{step > STEP_SHOWN_MS && (
<span
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
>
{formatMs(step)}
</span>
)}
{extra.length > 0 && (
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
)}
{waiting && onOpenTerminal && (
<button
type="button"
data-testid="chat-open-terminal"
onClick={onOpenTerminal}
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
>
<SquareTerminal className="h-3.5 w-3.5" />
{t("conversation.openTerminal")}
</button>
)}
</div>
{showAll && blocks.length > 1 && (
<ul className="ml-5 space-y-0.5 text-xs">
{blocks.map((b, i) => (
<li key={b.id ?? i} className="truncate">
{stepTitle(b)}
</li>
))}
</ul>
)}
</div>
);
}
/** Under a turn that ended: how long it took and the output it made. */
/** An option's label carries the model's " (Recommended)" marker into the
* answer the CLI records; the user's pick reads without it (every picked
* label of a multi-select answer too). */
function stripRecommended(answer: string | undefined): string | undefined {
return answer?.replace(/\s*\(recommended\)/gi, "").trim();
}
/** An AskUserQuestion: each question with the answer the user picked or
* typed (the result's `answers`, minus the " (Recommended)" marker),
* "declined" when they refused it, no answer yet while the CLI still asks. */
function AskCard({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const input = block.input as { questions?: unknown } | undefined;
const questions = (Array.isArray(input?.questions) ? input.questions : [])
.map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null))
.filter((q): q is string => typeof q === "string");
return (
<div
data-testid="chat-ask"
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
>
{questions.map((q, i) => {
const answer = stripRecommended(result?.answers?.[q]);
return (
<div key={i}>
<div className="flex items-start gap-1.5 text-fg-secondary">
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{q}</span>
</div>
{answer && (
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="whitespace-pre-wrap">{answer}</span>
</div>
)}
</div>
);
})}
{result?.is_error && (
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
)}
</div>
);
}
function TurnDone({ ms, output }: { ms: number; output: number }) {
const { t } = useTranslation("sessions");
return (
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
<Check className="h-3 w-3 flex-shrink-0" />
{[
t("conversation.turnDone", { time: formatMs(ms) }),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
]
.filter(Boolean)
.join(" · ")}
</div>
);
}
const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached image: its saved path under the
// drop dir (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
const DROP_IMAGE =
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
// What the prompt box sends for an attached file: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
const DROP_PATH =
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i;
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
/** A user turn's text without its dropped-image paths, and those images' names. */
export function splitDropImages(text: string): { text: string; images: string[] } {
/** A user turn's text without its dropped-file paths (and Claude Code's
* `[Image #N]` placeholders for them), the images' names and the other
* files' full paths. */
export function splitDropImages(text: string): {
text: string;
images: string[];
files: string[];
} {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
const files: string[] = [];
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
if (IMAGE_EXT.test(name)) images.push(name);
else files.push(full);
return "";
});
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
return {
text:
images.length || files.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
images,
files,
};
}
/** The drop paths of a meta message made only of `[Image: source: <drop>]` lines. */
function imageSourcePaths(msg: TranscriptMessage): string[] | null {
const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : []));
const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? "");
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
}
/** A dropped file other than an image: a chip that opens its preview when the
* page can (`OpenFileContext`). */
function DropFile({ path }: { path: string }) {
const open = useContext(OpenFileContext);
const name = path
.split(/[\\/]/)
.pop()!
.replace(/^[0-9a-f]{16}-?/i, "");
const ext = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toUpperCase();
return (
<button
type="button"
disabled={!open}
onClick={() => open?.(path)}
title={path}
className="flex max-w-64 items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-left text-xs enabled:hover:border-accent/50"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate text-fg-primary">{name}</span>
{ext && <span className="shrink-0 font-mono text-[10px] text-fg-muted">{ext}</span>}
</button>
);
}
function DropImage({ name }: { name: string }) {
@@ -707,6 +1160,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
[msg.content]
);
const images = blocks.flatMap((b) => b?.images ?? []);
const files = blocks.flatMap((b) => b?.files ?? []);
const hasText = blocks.some((b) => b?.text);
return (
<div
@@ -714,7 +1168,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
{images.length > 0 && (
{images.length + files.length > 0 && (
<div
data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2"
@@ -722,6 +1176,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
{images.map((name) => (
<DropImage key={name} name={name} />
))}
{files.map((path) => (
<DropFile key={path} path={path} />
))}
</div>
)}
{hasText && (
@@ -739,7 +1196,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{stripAnsi(text).trim()}
{typedText(stripAnsi(text).trim())}
</p>
) : (
renderTextBlock(text, b)
@@ -938,7 +1395,7 @@ function WorkStep({
/** What the assistant did between two replies as one line, opening onto
* one framed list of its steps. */
const WorkGroup = memo(function WorkGroup({
function WorkGroupLine({
blocks,
toolResultMap,
}: {
@@ -960,11 +1417,16 @@ const WorkGroup = memo(function WorkGroup({
</Disclosure>
</div>
);
});
}
const WorkGroup = memo(
WorkGroupLine,
(p, n) =>
sameBlocks(p.blocks, n.blocks) && sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
/** One user/assistant row. Memoized: a live tail append re-renders only the
* new rows; its thinking-block expansion is its own state, kept by key. */
const MessageRow = memo(function MessageRow({
function MessageRowView({
msg,
toolResultMap,
}: {
@@ -1083,4 +1545,11 @@ const MessageRow = memo(function MessageRow({
</div>
</div>
);
});
}
const MessageRow = memo(
MessageRowView,
(p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap)
);
/** Memoized: its owner re-renders on scroll state and its parent's typing. */
export const MessageList = memo(MessageListView);
@@ -0,0 +1,96 @@
/**
* @file ConversationView.hidden.test.tsx
* @description A chat kept mounted in a hidden pane (another workspace or
* tab, `display:none`) does not follow the live stream: a new_event for its
* session fetches nothing while its box is off screen, and showing it again
* catches the tail up once. On screen, a burst of events is throttled to one
* fetch at once plus one after the burst.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
import { api } from "../../../lib/api";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let intersect: ((visible: boolean) => void) | null = null;
const original = globalThis.IntersectionObserver;
beforeEach(() => {
intersect = null;
// @ts-expect-error test stub
globalThis.IntersectionObserver = class {
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
intersect = (visible) => cb([{ isIntersecting: visible }]);
}
observe() {}
disconnect() {}
};
});
afterEach(() => {
globalThis.IntersectionObserver = original;
vi.useRealTimers();
vi.clearAllMocks();
});
const event = () =>
act(() => eventBus.publish({ type: "new_event", data: { session_id: "s1" } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView in a hidden pane", () => {
it("fetches nothing while hidden and catches up once when shown", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
act(() => intersect?.(false));
event();
event();
expect(calls()).toBe(before);
act(() => intersect?.(true));
await act(async () => {});
expect(calls()).toBe(before + 1);
});
it("throttles a burst of events on screen", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
event();
event();
event();
// The first fetches at once; the rest wait for the throttle window.
expect(calls()).toBe(before + 1);
await act(async () => {
await vi.advanceTimersByTimeAsync(1600);
});
expect(calls()).toBe(before + 2);
});
});
@@ -0,0 +1,58 @@
/**
* @file ConversationView.notYet.test.tsx
* @description A live pane's new session has no row until its first hook:
* its 404 shows no "Session not found" (a replay still shows it) but says the
* session has not started — the CLI may be asking in the terminal, e.g. to
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
const transcript = vi.hoisted(() =>
vi.fn(async () => {
throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 });
})
);
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } },
};
});
describe("ConversationView before a session's first hook", () => {
it("shows no error for a live pane", async () => {
render(<ConversationView sessionId="new" fill live />);
await waitFor(() => expect(transcript).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Session not found")).toBeNull();
});
it("still reports it when not live", async () => {
render(<ConversationView sessionId="gone" fill />);
expect(await screen.findByText("Session not found")).toBeTruthy();
});
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
const onOpenTerminal = vi.fn();
const onNotStarted = vi.fn();
render(
<ConversationView
sessionId="new"
fill
live
onOpenTerminal={onOpenTerminal}
onNotStarted={onNotStarted}
/>
);
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
expect(screen.queryByText("No conversation records found.")).toBeNull();
fireEvent.click(screen.getByText("Open terminal"));
expect(onOpenTerminal).toHaveBeenCalled();
expect(onNotStarted).toHaveBeenLastCalledWith(true);
});
});
@@ -7,8 +7,52 @@
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MarkdownContent } from "../MarkdownContent";
import { vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
import { resolveIn } from "../../../lib/resolvePath";
function withOpen(text: string) {
const open = vi.fn();
render(
<OpenFileContext.Provider value={open}>
<MarkdownContent text={text} />
</OpenFileContext.Provider>
);
return open;
}
describe("file links", () => {
it("opens a relative path with its line suffix stripped", () => {
const open = withOpen("See server/lib/x.js:42 for it.");
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
expect(open).toHaveBeenCalledWith("server/lib/x.js");
});
it("opens an absolute path with a line suffix and one in inline code", () => {
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
});
it("opens a markdown link to a file instead of navigating", () => {
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
});
it("leaves prose with slashes as text", () => {
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
expect(screen.queryAllByRole("button")).toHaveLength(0);
});
it("resolves a relative path against the pane's folder", () => {
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
});
});
describe("<MarkdownContent />", () => {
it("renders fenced code blocks with the language label", () => {
@@ -66,10 +110,61 @@ describe("<MarkdownContent />", () => {
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
});
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
const { container } = render(
<MarkdownContent
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
});
it("highlights an ISO date-time as one number", () => {
const { container } = render(
<MarkdownContent
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual([
"2026-10-12T08:00",
"2026-10-12T08:00:00Z",
"2026-10-12T08:00:00.123+07:00",
]);
});
it("does not highlight numbers inside inline code", () => {
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
expect(container.querySelector("span.tabular-nums")).toBeNull();
});
it("colours a flag label by how serious it is", () => {
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
expect(screen.queryByText("Note")).toBeNull();
});
it("handles empty input safely", () => {
const { container } = render(<MarkdownContent text="" />);
// Wrapper exists but no block elements
expect(container.firstChild).not.toBeNull();
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
});
it("renders a table that follows prose with no blank line (issue #83)", () => {
const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |';
const { container } = render(<MarkdownContent text={text} />);
expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:");
expect(container.querySelectorAll("th").length).toBe(2);
expect(container.querySelectorAll("tbody tr").length).toBe(1);
expect(container.textContent).not.toContain("|---|");
});
it("accepts a GFM divider with single dashes", () => {
const { container } = render(<MarkdownContent text={"| a | b |\n|-|:-:|\n| 1 | 2 |"} />);
expect(container.querySelectorAll("th").length).toBe(2);
});
});
@@ -0,0 +1,104 @@
/**
* @file MessageList.ask.test.tsx
* @description An AskUserQuestion call is not folded into the collapsed work
* line: the chat shows it as its own card — each question with the answer the
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
* CLI still waits.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-13T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const ask = (id: string, questions: string[]): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
{
type: "tool_use",
id,
name: "AskUserQuestion",
input: {
questions: questions.map((question) => ({
question,
header: "Pick",
options: [{ label: "Yes" }, { label: "No" }],
})),
},
},
],
}) as TranscriptMessage;
const result = (id: string, extra: Record<string, unknown>) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "x", ...extra }],
}) as TranscriptMessage;
describe("an AskUserQuestion card", () => {
it("shows each question with the user's answer, outside the work line", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Push?"]),
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("only after review")).toBeTruthy();
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
});
it("drops the (Recommended) marker from a picked answer", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Tools?"]),
result("q1", {
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
}),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("Lint, Test")).toBeTruthy();
expect(within(card).queryByText(/Recommended/)).toBeNull();
});
it("says declined when refused, and shows no answer while still asked", () => {
const { rerender } = render(
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).queryByText(/declined/i)).toBeNull();
rerender(
<MessageList
loading={false}
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
/>
);
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
});
});
@@ -2,8 +2,9 @@
* @file MessageList.edits.test.tsx
* @description Each assistant turn that changed files ends with Claude
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, and a row click handed
* to the caller.
* removed summed over the turn, a failed call left out, a row click handed
* to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
@@ -67,4 +68,36 @@ describe("the Edited N files card", () => {
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
});
it("drags a row as its @path attachment, and the header as every file", () => {
render(
<MessageList
loading={false}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 1, removed: 0 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 2, removed: 0 }),
]}
/>
);
const card = screen.getByTestId("edited-files");
const drag = (el: Element) => {
const data: Record<string, string> = {};
fireEvent.dragStart(el, {
dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" },
});
return data;
};
const one = drag(within(card).getByTitle("/w/b.md"));
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
{ path: "@/w/b.md", name: "b.md" },
]);
expect(one["text/plain"]).toBe("/w/b.md");
const all = drag(within(card).getByText("Edited 2 files"));
expect(
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.md"]);
});
});
@@ -2,8 +2,10 @@
* @file MessageList.images.test.tsx
* @description An image the prompt box attached reaches the transcript as its
* saved drop path; the user's turn shows the image (served by
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
* other path as typed.
* `GET /api/run/drop/:name`), opens it full size on the page; any other
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
* URLs and absolute paths in a turn are links.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
@@ -17,20 +19,45 @@ describe("dropped images in a user turn", () => {
it("splitDropImages takes drop-dir image paths out of the text", () => {
expect(
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
]);
// Not a drop, or not an image: left as typed.
for (const text of [
"/home/me/shot.png",
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
]) {
expect(splitDropImages(text)).toEqual({ text, images: [] });
// The drop dir since the data-dir move.
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
text: "fix this",
images: [SHOT],
files: [],
});
// Any other dropped file comes out by its full path.
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
// Not a drop: left as typed.
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
}
});
it("shows a dropped file as a chip that opens its preview", () => {
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `summarise ${pdf}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).not.toHaveTextContent("0123456789abcdef");
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
expect(opened).toEqual([pdf]);
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
@@ -51,4 +78,56 @@ describe("dropped images in a user turn", () => {
fireEvent.click(img);
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
});
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
},
{
type: "user",
sender: "system",
timestamp: "2026-10-09T09:43:11.000Z",
content: [
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
expect(screen.queryByText("System")).toBeNull();
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
expect(bubble).not.toHaveTextContent("[Image #1]");
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
`/api/run/drop/${SHOT}`
);
});
it("makes URLs and absolute paths clickable", () => {
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [
{
type: "text",
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
},
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
expect(link.getAttribute("target")).toBe("_blank");
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
});
});
@@ -4,12 +4,13 @@
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line.
* and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, workLabel, workingOn } from "../MessageList";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
}
describe("MessageList — sender attribution", () => {
it("shows a typed /command and @path as chips in the user's bubble", () => {
render(
<MessageList
messages={[
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
]}
loading={false}
/>
);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("/review @src/a.ts now");
expect(within(bubble).getByText("/review").className).toContain("text-accent");
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
});
it("labels each row by its sender, not blanket 'User'", () => {
const messages: TranscriptMessage[] = [
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
@@ -126,11 +142,17 @@ describe("MessageList — sender attribution", () => {
it("ends a live chat with the step still running", () => {
const messages = [
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
msg({
type: "user",
sender: "user",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [{ type: "text", text: "test it" }],
}),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(),
timestamp: new Date(Date.now() - 5_000).toISOString(),
usage: { input_tokens: 2, output_tokens: 3500 },
content: [
{
type: "tool_use",
@@ -144,7 +166,8 @@ describe("MessageList — sender attribution", () => {
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite");
expect(row).toHaveTextContent(/1m \ds/);
// Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
@@ -159,6 +182,121 @@ describe("MessageList — sender attribution", () => {
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
// Issue #74: Esc before the model wrote anything takes the prompt back into
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
const prompt = msg({
sender: "user",
timestamp: new Date(Date.now() - 14_000).toISOString(),
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
});
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
// The next prompt is a new turn: its Thinking… shows.
const next = msg({
sender: "user",
timestamp: new Date().toISOString(),
content: [{ type: "text", text: "again" }],
});
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
});
it("counts a turn's output once per API call, since its prompt", () => {
const at = "2026-06-26T08:19:00.000Z";
const line = (cache: number, out: number) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "x" }],
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
});
const messages = [
msg({ sender: "user" }),
line(10, 999), // an earlier turn
msg({ sender: "user", timestamp: at }),
line(100, 40),
line(100, 40), // the same call's second block
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
line(200, 25),
];
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
});
it("keeps every parallel call of the turn running until its own result", () => {
const at = "2026-06-26T08:19:00.000Z";
const now = Date.parse("2026-06-26T08:20:00.000Z");
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
});
const done = (id: string) =>
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
// Claude Code writes each parallel call on its own line; one result is in.
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
const results = new Map([["b", m[4]!.content[0]!]]);
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
// A call left unpaired in an EARLIER turn is not this one's.
const next = [
...m,
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
];
expect(workingOn(next, results, now)?.blocks).toEqual([]);
});
it("says when the CLI waits on the user, and offers the terminal", () => {
let opened = 0;
render(
<MessageList
loading={false}
live
waiting
onOpenTerminal={() => opened++}
messages={[
msg({ sender: "user", timestamp: new Date().toISOString() }),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date().toISOString(),
content: [
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
],
}),
]}
/>
);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
fireEvent.click(screen.getByTestId("chat-open-terminal"));
expect(opened).toBe(1);
});
it("closes an ended turn with its time and output", () => {
const reply = (out: number, stop_reason: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: "2026-06-26T08:16:08.000Z",
stop_reason,
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
content: [{ type: "text", text: "done" }],
});
render(
<MessageList
loading={false}
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
/>
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
});
it("knows what a live turn is doing until it ends", () => {
const now = Date.parse("2026-06-26T08:20:00.000Z");
const at = "2026-06-26T08:19:00.000Z";
@@ -189,10 +327,11 @@ describe("MessageList — sender attribution", () => {
);
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
const thinking = { blocks: [], since: null, last: at };
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
const stopped = msg({
+205 -98
View File
@@ -1,13 +1,17 @@
/**
* @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). 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,7 +25,7 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, X } from "lucide-react";
import { X } from "lucide-react";
import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
@@ -29,7 +33,6 @@ import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { readDefaultTool } from "../../lib/sessionDefaults";
/** Polls the lane until the background `git worktree add` finishes (status
@@ -48,6 +51,17 @@ async function waitForProvisioned(
}
}
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
export function cloneDirName(url: string): string {
return (
url
.trim()
.replace(/[/:]+$/, "")
.split(/[/:]/)
.pop() || ""
).replace(/\.git$/i, "");
}
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
function Seg({
active,
@@ -97,7 +111,18 @@ 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 [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 +135,18 @@ export function AddLaneModal({
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState("");
const [branchesError, setBranchesError] = useState<string | null>(null);
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
// No picker: a workspace's tool is only its DEFAULT — each new session
// picks its own AI on the workspace home. This is the default AI tool
// (Settings → AI Tools), the account's own pick first while it is
// installed and on.
const aiTools = useAiTools();
// 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 +157,10 @@ export function AddLaneModal({
const reset = () => {
setMode("repo");
setCloneUrl("");
setCloneName("");
setLogin(null);
setSshRefused(false);
setSourceRepo("");
setTitle("");
setBranch("");
@@ -148,7 +168,6 @@ export function AddLaneModal({
setWorktrees([]);
setBranchMode("new");
setBase("");
setPickedTool(null);
setBranchesError(null);
setError(null);
setBusy(false);
@@ -213,6 +232,52 @@ export function AddLaneModal({
setBusy(true);
setError(null);
if (mode === "clone") {
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
try {
const result = await api.lanes.clone({
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onAdded(result.lane);
reset();
onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || url;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
token: "",
remember: l?.remember ?? true,
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
} else {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
}
return;
}
if (mode === "repo") {
try {
const result = await api.lanes.ensure({
@@ -328,7 +393,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 +402,8 @@ export function AddLaneModal({
const disabled =
!done &&
(!sourceRepo.trim() ||
(mode === "clone" &&
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
(mode === "worktree" &&
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
@@ -394,81 +460,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 +519,70 @@ 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"
/>
</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 +796,14 @@ export function AddLaneModal({
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
{sshRefused && (
<>
{" "}
<a href="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
</>
)}
</p>
)}
</div>
@@ -721,7 +824,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,188 @@
/**
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
* added as an extra folder, one after another. A host that wants a login
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
* URLs with it, like Add workspace → Clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type LaneSourcesView } from "../../lib/api";
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
type Login = { host: string; username: string; token: string; remember: boolean };
export function CloneSourcesForm({
laneId,
parent,
onAdded,
}: {
laneId: number;
/** Where the clones land: the workspace folder's parent, shown only. */
parent: string;
onAdded: (view: LaneSourcesView) => void;
}) {
const { t } = useTranslation("lanes");
const [text, setText] = useState("");
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null);
const urls = (s: string) => [
...new Set(
s
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
),
];
const cloneAll = async (list: string[]) => {
if (busy || !list.length) return;
setBusy(true);
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
const show = () => setRows([...next]);
show();
let asked: Login | null = null;
for (const row of next) {
row.state = "busy";
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
row.state = "done";
onAdded(view);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || row.url;
row.state = "failed";
if (e.code === "ECREDENTIALS") {
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
asked ??= {
host,
username: login?.username ?? "",
token: "",
remember: login?.remember ?? true,
};
} else if (e.code === "ESSHAUTH") {
row.note = t("clone.sshRefused", { host });
} else {
row.note = e.message || String(err);
}
}
show();
}
if (asked) setLogin(asked);
// What is left to do stays in the box; what was added leaves it.
setText(
next
.filter((r) => r.state !== "done")
.map((r) => r.url)
.join("\n")
);
setBusy(false);
};
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
return (
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
<textarea
data-testid="workspace-clone-input"
aria-label={t("home.cloneFolders")}
placeholder={t("home.clonePlaceholder")}
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
{login && (
<div className="space-y-1.5 rounded border border-border-light p-2">
<div className="flex gap-2">
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
/>
<input
type="password"
aria-label={t("clone.token")}
placeholder={t("clone.token")}
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
/>
</div>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
/>
{t("clone.remember")}
</label>
</div>
)}
<div className="flex justify-end gap-2">
{failed.length > 0 && !busy && (
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
{t("home.cloneRetry")}
</button>
)}
<button
type="button"
data-testid="workspace-clone-run"
disabled={busy || !urls(text).length}
onClick={() => void cloneAll(urls(text))}
className="btn-ghost text-xs"
>
{busy ? t("clone.cloning") : t("home.cloneRun")}
</button>
</div>
{rows.length > 0 && (
<ul className="space-y-1" data-testid="workspace-clone-rows">
{rows.map((r) => (
<li key={r.url} className="text-[11px]">
<span className="flex items-center gap-1.5">
{r.state === "busy" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
) : r.state === "done" ? (
<Check
className="h-3 w-3 shrink-0 text-status-success"
aria-label={t("home.cloneDone")}
/>
) : r.state === "failed" ? (
<X className="h-3 w-3 shrink-0 text-status-danger" />
) : (
<span className="h-3 w-3 shrink-0" />
)}
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
{r.url}
</span>
</span>
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
</li>
))}
</ul>
)}
</div>
);
}
+38 -33
View File
@@ -21,7 +21,7 @@
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip at the end of its first line (inline, so it covers no code),
* Chat chip floating above its last line, at the column where it ends,
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
@@ -50,6 +50,8 @@ import {
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type Tooltip,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
class ChatChip extends WidgetType {
constructor(
readonly start: number,
readonly end: number,
readonly build: (start: number, end: number) => HTMLElement
) {
super();
}
eq(o: ChatChip) {
return this.start === o.start && this.end === o.end;
}
toDOM() {
return this.build(this.start, this.end);
}
}
/** Add to Chat inline at the end of a non-empty selection's first line —
* the empty space right of the code, so it covers no line. `build` makes its
* DOM from the selection's first and last 1-based line. A selection ending
* at a line's start (a whole-line drag) does not take that line. */
/** Add to Chat floating just above the selection's last line, at the column
* where the selection ends — so a long line never pushes it out of view.
* `build` makes its DOM from the selection's first and last 1-based line. A
* selection ending at a line's start (a whole-line drag) does not take that
* line, and the chip then sits at the start of the last line it does take. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
if (!tr.docChanged && !tr.selection) return deco;
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
const { from, to, empty } = tr.state.selection.main;
if (empty) return Decoration.none;
if (empty) return null;
const doc = tr.state.doc;
const first = doc.lineAt(from);
const last = doc.lineAt(to);
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
const widget = new ChatChip(first.number, end, build);
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
const wholeLine = to === last.from && last.number > first.number;
const end = wholeLine ? last.number - 1 : last.number;
return {
pos: wholeLine ? doc.line(end).from : to,
above: true,
create: () => {
const dom = build(first.number, end);
dom.classList.add("cm-chat-chip");
return { dom };
},
};
},
provide: (f) => EditorView.decorations.from(f),
provide: (f) => showTooltip.from(f),
});
}
@@ -355,9 +350,19 @@ const theme = EditorView.theme({
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgb(var(--accent) / 0.3)",
// Doubled classes: drawSelection's base theme scopes its light/dark colours
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
// plain `.cm-selectionBackground` never outranks.
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
{
background: "rgb(var(--accent) / 0.3)",
},
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
border: "1px solid rgb(var(--accent) / 0.6)",
backgroundColor: "rgb(var(--surface-1))",
},
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${M} .cm-changedText`]: { background: "none" },
@@ -544,7 +549,7 @@ export default function DiffEditor({
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
File diff suppressed because it is too large Load Diff
+652 -69
View File
@@ -1,46 +1,178 @@
/**
* @file A server-backed folder browser for the Add Lane modal's path inputs.
* Browsers refuse to expose an absolute filesystem path from a native folder
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
* up-one-level button and a click-to-descend list, since this tool is
* local-first and the server already trusts arbitrary local paths.
* @file A server-backed folder (or file) browser for the path inputs' Browse
* button, laid out like the OS's own picker. Browsers refuse to expose an
* absolute filesystem path from a native picker, so path selection here is
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
* A click selects, a double-click or Enter opens a folder (picks a file),
* Backspace goes up and typing jumps to a name.
* An admin with a Linux account of their own (S3; the listing carries
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
* browser to the host account's home, where a workspace runs as the shared
* host account. It is never remembered: every open starts at the admin's own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import {
ArrowLeft,
ArrowRight,
ArrowUp,
Clock,
Eye,
File,
FolderGit2,
FolderOpen,
FolderPlus,
Home,
Pencil,
Server,
Star,
} from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { useDialog } from "../../hooks/useDialog";
import { useWorkLanes } from "../../hooks/useLanes";
export function FolderBrowseModal({
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
type Entry = Listing["entries"][number];
const RECENT_KEY = "agenthub.browse.recent";
const HIDDEN_KEY = "agenthub.browse.hidden";
const RECENT_MAX = 8;
function readStored<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function writeStored(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// A private window or blocked storage: the preference just isn't kept.
}
}
/** `[label, path]` for each level of `p`, root first. */
function crumbs(p: string): [string, string][] {
const win = /^[A-Za-z]:\\/.test(p);
const sep = win ? "\\" : "/";
const parts = p.split(sep).filter(Boolean);
const out: [string, string][] = win ? [] : [["/", "/"]];
let acc = "";
for (const part of parts) {
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
out.push([part, acc]);
}
return out;
}
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
type Props = {
open: boolean;
/** `file` lists files too, and picking one is the only way to select. */
kind?: "dir" | "file";
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
onClose: () => void;
};
/**
* Mounted only while open, so a closed picker (one behind every Browse
* button) asks the server for nothing, and each opening starts fresh.
*/
export function FolderBrowseModal(props: Props) {
return props.open ? <BrowseDialog {...props} /> : null;
}
function BrowseDialog({
open,
kind = "dir",
initialPath,
onSelect,
onClose,
}: {
open: boolean;
/** `file` lists files too, and picking one is the only way to select. */
kind?: "dir" | "file";
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
const { lanes } = useWorkLanes();
const [listing, setListing] = useState<Listing | null>(null);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
const [selected, setSelected] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [editing, setEditing] = useState(false);
const [pathText, setPathText] = useState("");
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
key: "name",
dir: 1,
});
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
// The highest folder this user may open (a member's own folder answers no
// parent) — breadcrumbs above it are not links.
const [floor, setFloor] = useState<string | null>(null);
const [home, setHome] = useState<string | null>(null);
// An admin's "Server folder" tick (S3): browse the host account's home.
const [hostHome, setHostHome] = useState<string | null>(null);
const [onHost, setOnHost] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const crumbRef = useRef<HTMLDivElement>(null);
const typeAhead = useRef({ text: "", at: 0 });
const load = async (path?: string) => {
const load = async (
target?: string,
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
) => {
setError(null);
try {
setListing(await api.lanes.browse(path));
const next = await api.lanes.browse(target, kind === "file", hidden, host);
setListing(next);
setSelected(select);
setFilter("");
setEditing(false);
if (next.home) setHome(next.home);
setHostHome(next.hostHome ?? null);
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
if (push) {
setHistory((h) => {
if (h.stack[h.at] === next.path) return h;
const stack = [...h.stack.slice(0, h.at + 1), next.path];
return { stack, at: stack.length - 1 };
});
}
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
}
};
useEffect(() => {
if (open) void load(initialPath);
if (!open) return;
setHistory({ stack: [], at: -1 });
setCreating(false);
setOnHost(false);
void load(initialPath, { host: false });
// Only re-run when the modal actually opens - not on every initialPath
// keystroke in the field behind it.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -51,8 +183,171 @@ export function FolderBrowseModal({
const panel = useRef<HTMLDivElement>(null);
useDialog(open, panel, onClose);
const entries = useMemo(() => {
const needle = filter.trim().toLowerCase();
const list = (listing?.entries ?? []).filter(
(e) => !needle || e.name.toLowerCase().includes(needle)
);
const by = (a: Entry, b: Entry) =>
sort.key === "mtime"
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
sort.dir;
// Folders first, as every file picker lists them.
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
}, [listing, filter, sort]);
const places = useMemo(() => {
const seen = new Set<string>();
return lanes
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
}, [lanes]);
// A deep path keeps its LAST levels in view, like a native path bar.
useEffect(() => {
const el = crumbRef.current;
if (el) el.scrollLeft = el.scrollWidth;
}, [listing?.path, editing]);
useEffect(() => {
if (!selected) return;
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
(x) => x.dataset.path === selected
);
el?.scrollIntoView?.({ block: "nearest" });
}, [selected]);
if (!open) return null;
const finish = (picked: string) => {
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
setRecent(next);
writeStored(RECENT_KEY, next);
onSelect(picked);
onClose();
};
const activate = (entry: Entry) => {
if (entry.file) finish(entry.path);
else void load(entry.path);
};
const go = (delta: -1 | 1) => {
const at = history.at + delta;
const target = history.stack[at];
if (target === undefined) return;
void load(target, { push: false }).then((ok) => {
if (ok) setHistory((h) => ({ ...h, at }));
});
};
const goUp = () => {
if (!listing?.parent) return;
const from = listing.path;
void load(listing.parent, { select: from });
};
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
const target =
kind === "file"
? selectedEntry?.file
? selectedEntry.path
: null
: selectedEntry && !selectedEntry.file
? selectedEntry.path
: (listing?.path ?? null);
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
const idx = entries.findIndex((x) => x.path === selected);
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (e.altKey && e.key === "ArrowUp") return goUp();
const next =
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
if (entries[next]) setSelected(entries[next].path);
return;
}
if (e.key === "Home" || e.key === "End") {
e.preventDefault();
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
if (pick) setSelected(pick.path);
return;
}
if (e.key === "Enter") {
e.preventDefault();
if (selectedEntry) activate(selectedEntry);
else if (kind === "dir" && target) finish(target);
return;
}
if (e.key === "Backspace") {
e.preventDefault();
goUp();
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Typing jumps to the first name that starts with what was typed.
const now = Date.now();
const ta = typeAhead.current;
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
ta.at = now;
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
if (hit) setSelected(hit.path);
}
};
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
e.preventDefault();
setPathText(listing?.path ?? "");
setEditing(true);
} else if (e.altKey && e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.altKey && e.key === "ArrowRight") {
e.preventDefault();
go(1);
}
};
const createFolder = async () => {
const name = newName.trim();
if (!name || !listing) return;
setError(null);
try {
const made = await api.lanes.mkdir(listing.path, name);
setCreating(false);
setNewName("");
await load(listing.path, { push: false, select: made.path });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
// Switch between the admin's own home and the host account's, and open it.
const toggleHost = () => {
const next = !onHost;
setOnHost(next);
void load(undefined, { host: next });
};
const toggleHidden = () => {
const next = !showHidden;
setShowHidden(next);
writeStored(HIDDEN_KEY, next);
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
};
const iconBtn =
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
const placeBtn = (active: boolean) =>
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
}`;
const path = listing?.path ?? "";
const floorDepth = floor ? crumbs(floor).length : 0;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@@ -61,74 +356,362 @@ export function FolderBrowseModal({
>
<div
ref={panel}
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
onKeyDown={onPanelKey}
role="dialog"
aria-modal="true"
aria-label={t("browse.title")}
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
data-tour="folder-browse"
>
<div className="border-b border-border p-3">
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
{listing?.path || "…"}
{/* Toolbar: history, up, and the breadcrumb / path field. */}
<div className="flex items-center gap-1 border-b border-border p-2">
<button
type="button"
className={iconBtn}
onClick={() => go(-1)}
disabled={history.at <= 0}
title={t("browse.back")}
aria-label={t("browse.back")}
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
className={iconBtn}
onClick={() => go(1)}
disabled={history.at >= history.stack.length - 1}
title={t("browse.forward")}
aria-label={t("browse.forward")}
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={iconBtn}
onClick={goUp}
disabled={!listing?.parent}
title={t("browse.up")}
aria-label={t("browse.up")}
>
<ArrowUp className="h-4 w-4" />
</button>
{editing ? (
<input
autoFocus
value={pathText}
onChange={(e) => setPathText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
if (pathText.trim()) void load(pathText.trim());
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setEditing(false);
}
}}
onBlur={() => setEditing(false)}
aria-label={t("browse.editPath")}
spellCheck={false}
className="input min-w-0 flex-1 font-mono text-xs"
/>
) : (
<div
ref={crumbRef}
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
onDoubleClick={() => {
setPathText(path);
setEditing(true);
}}
>
{crumbs(path).map(([label, p], i, all) => {
const last = i === all.length - 1;
const locked = i + 1 < floorDepth;
return (
<span key={p} className="flex shrink-0 items-center">
{i > 0 && all[i - 1]?.[0] !== "/" && (
<span className="px-0.5 text-fg-muted">›</span>
)}
<button
type="button"
disabled={last || locked}
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
className={`rounded px-1 py-0.5 font-mono text-xs ${
last
? "font-medium text-fg-primary"
: locked
? "text-fg-muted"
: "text-fg-secondary hover:bg-surface-2"
}`}
>
{label}
</button>
</span>
);
})}
</div>
)}
<button
type="button"
className={iconBtn}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setPathText(path);
setEditing((v) => !v);
}}
title={t("browse.editPath")}
aria-label={t("browse.editPath")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex min-h-0 flex-1">
{/* Places */}
<nav
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
aria-label={t("browse.places")}
>
{home && (
<button
type="button"
className={placeBtn(path === home)}
onClick={() => void load(home)}
title={home}
>
<Home className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{t("browse.home")}</span>
</button>
)}
{places.length > 0 && (
<>
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
<Star className="h-3 w-3" />
{t("browse.workspaces")}
</div>
{places.map((pl) => (
<button
key={pl.path}
type="button"
className={placeBtn(path === pl.path)}
onClick={() => void load(pl.path)}
title={pl.path}
>
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">{pl.label}</span>
</button>
))}
</>
)}
{recent.length > 0 && (
<>
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
<Clock className="h-3 w-3" />
{t("browse.recent")}
</div>
{recent.map((r) => (
<button
key={r}
type="button"
className={placeBtn(path === r)}
onClick={() => void load(r)}
title={r}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{baseName(r)}</span>
</button>
))}
</>
)}
</nav>
{/* Listing */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border p-2">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (entries[0]) setSelected(entries[0].path);
listRef.current?.focus();
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
e.preventDefault();
activate(entries[0]);
}
}}
placeholder={t("browse.filter")}
aria-label={t("browse.filter")}
className="input min-w-0 flex-1 text-xs"
/>
</div>
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
{(["name", "mtime"] as const).map((key) => (
<button
key={key}
type="button"
onClick={() =>
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
}
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
>
{key === "name" ? t("browse.name") : t("browse.modified")}
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
</button>
))}
</div>
<div
ref={listRef}
tabIndex={0}
role="listbox"
aria-label={path}
onKeyDown={onListKey}
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
>
{error && (
<p role="alert" className="p-2 text-xs text-status-danger">
{error}
</p>
)}
{creating && (
<div className="flex items-center gap-2 px-2 py-1">
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (isSubmitEnter(e)) {
e.preventDefault();
void createFolder();
} else if (e.key === "Escape") {
e.preventDefault();
setCreating(false);
}
}}
placeholder={t("browse.newFolderName")}
aria-label={t("browse.newFolderName")}
className="input min-w-0 flex-1 text-xs"
/>
<button
type="button"
onClick={() => void createFolder()}
disabled={!newName.trim()}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.create")}
</button>
</div>
)}
{entries.map((entry) => {
const isSel = entry.path === selected;
return (
<div
key={entry.path}
data-path={entry.path}
role="option"
aria-selected={isSel}
onClick={() => setSelected(entry.path)}
onDoubleClick={() => activate(entry)}
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
}`}
>
{entry.file ? (
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
) : entry.isGitRepo ? (
<FolderGit2
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
/>
) : (
<FolderOpen
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
/>
)}
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<span
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
>
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
</span>
</div>
);
})}
{listing && entries.length === 0 && (
<p className="p-2 text-xs text-fg-muted">
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
</p>
)}
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-1">
{error && (
<p role="alert" className="p-2 text-xs text-status-danger">
{error}
</p>
)}
{listing?.parent && (
{/* Footer: what will be picked, and the actions. */}
<div className="border-t border-border p-2">
<div
// rtl so a long path loses its START to the ellipsis, not the folder.
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
title={target ?? undefined}
>
<bdi>{target ?? "…"}</bdi>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => load(listing.parent!)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
onClick={() => {
setNewName("");
setCreating(true);
}}
disabled={!listing}
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
>
<ArrowUp className="h-3.5 w-3.5" />
..
<FolderPlus className="h-3.5 w-3.5" />
{t("browse.newFolder")}
</button>
)}
{listing?.entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => load(entry.path)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
<span className="truncate">{entry.name}</span>
</button>
))}
{listing && listing.entries.length === 0 && !listing.parent && (
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
<button
type="button"
onClick={onClose}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
<Eye className="h-3.5 w-3.5" />
{t("browse.showHidden")}
</label>
{hostHome && (
<label
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
title={t("browse.hostFolderHint", { path: hostHome })}
>
<input
type="checkbox"
checked={onHost}
onChange={toggleHost}
data-testid="browse-host-folder"
/>
<Server className="h-3.5 w-3.5" />
{t("browse.hostFolder")}
</label>
)}
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
{t("browse.hint")}
</span>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!target}
onClick={() => target && finish(target)}
className="btn-primary text-xs disabled:opacity-50"
>
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,155 @@
/**
* @file InterruptedSessionsPrompt.tsx
* @description The app-wide "resume interrupted sessions?" dialog. A session
* whose pane died under it — the machine shut down, tmux was killed, the CLI
* crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones
* to resume (each in the first free tab of its workspace, with its run's
* model and effort) or resumes none. Either answer dismisses the whole list,
* so it is asked once; a resume that failed stays listed. Refetched on the
* `runs_interrupted` broadcast, so a tmux that dies under an open dashboard
* asks too. Resuming is the explicit click here, never the dialog opening.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type InterruptedRun } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { timeAgo } from "../../lib/format";
import { toast } from "../../lib/toast";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd;
export function InterruptedSessionsPrompt() {
const { t } = useTranslation("run");
const navigate = useNavigate();
const [items, setItems] = useState<InterruptedRun[]>([]);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
// A refetch keeps the user's ticks; a session not seen before starts ticked.
const seen = useRef(new Set<string>());
const load = useCallback(() => {
api.run
.interrupted()
.then((r) => {
const fresh = r.items.filter((i) => !seen.current.has(i.session_id));
fresh.forEach((i) => seen.current.add(i.session_id));
setItems(r.items);
setPicked((p) => {
const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id));
fresh.forEach((i) => next.add(i.session_id));
return next;
});
})
.catch(() => undefined); // a convenience: no prompt when it cannot be read
}, []);
useEffect(load, [load]);
useEffect(
() =>
eventBus.subscribe((msg) => {
if (msg.type === "runs_interrupted") load();
}),
[load]
);
const dismiss = async (sessionIds: string[]) => {
if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error);
};
const resumeNone = async () => {
if (busy) return;
setBusy(true);
await dismiss(items.map((i) => i.session_id));
setItems([]);
setBusy(false);
};
const resumePicked = async () => {
if (busy) return;
setBusy(true);
const failed = new Set<string>();
let first: { runId: string; laneId: number } | null = null;
for (const it of items.filter((i) => picked.has(i.session_id))) {
try {
const r = await startInFreeTabDetailed(it.lane_id, {
resumeSessionId: it.session_id,
...(it.model ? { model: it.model } : {}),
...(it.effort ? { effort: it.effort } : {}),
// Bypass is a per-run choice, never carried over.
...(it.permission_mode && it.permission_mode !== "bypassPermissions"
? { permissionMode: it.permission_mode }
: {}),
});
first ??= r;
} catch (err) {
failed.add(it.session_id);
toast.error(
t("interrupted.failed", {
name: label(it),
message: err instanceof Error ? err.message : String(err),
})
);
}
}
await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id)));
setItems((cur) => cur.filter((i) => failed.has(i.session_id)));
setBusy(false);
if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`);
};
const toggle = (id: string, on: boolean) =>
setPicked((p) => {
const next = new Set(p);
if (on) next.add(id);
else next.delete(id);
return next;
});
const count = items.filter((i) => picked.has(i.session_id)).length;
return (
<ConfirmModal
open={items.length > 0}
title={t("interrupted.title")}
message={t("interrupted.message")}
confirmLabel={t("interrupted.resume", { count })}
cancelLabel={t("interrupted.none")}
destructive={false}
busy={busy}
disabled={count === 0}
onConfirm={() => void resumePicked()}
onCancel={() => void resumeNone()}
>
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
{items.map((it) => (
<li key={it.session_id} className="rounded-md border border-border px-2 py-1.5">
<Checkbox
checked={picked.has(it.session_id)}
onChange={(on) => toggle(it.session_id, on)}
disabled={busy}
className="w-full"
labelClassName="min-w-0 flex-1"
label={
<span className="block min-w-0 text-left">
<span className="block truncate text-xs text-fg-primary">
{label(it)} · {it.tool}
</span>
<span className="block truncate text-[11px] text-fg-muted">
{it.session_name || it.prompt_preview || it.session_id}
{it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""}
</span>
</span>
}
/>
</li>
))}
</ul>
</ConfirmModal>
);
}
+186 -14
View File
@@ -87,6 +87,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Download,
Code2,
Columns2,
Eye,
@@ -103,6 +104,7 @@ import {
UnfoldVertical,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
@@ -121,6 +123,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";
@@ -643,6 +646,8 @@ function ChangeRow({
<button
type="button"
onClick={() => onOpen(f.path)}
draggable
onDragStart={(ev) => dragProjectPath(ev, f.path)}
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span
@@ -805,8 +810,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). */
@@ -850,6 +859,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.
@@ -865,6 +877,16 @@ export function LaneChangesPanel({
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [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.
@@ -937,6 +959,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.
@@ -1066,19 +1095,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
@@ -1612,8 +1654,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>
@@ -1859,6 +1925,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);
@@ -2045,6 +2166,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={
@@ -2146,14 +2297,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"
@@ -2508,7 +2661,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>
@@ -2564,13 +2719,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")}
@@ -2756,6 +2914,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. */}
@@ -2836,7 +3006,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}
+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>
)}
@@ -121,7 +121,11 @@ export function SessionReplay({
};
return (
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
<div
data-testid="session-replay"
data-prompt-drop
className="flex min-h-0 flex-1 flex-col gap-2"
>
{session && (
<div className="flex justify-end">
<SessionResumeButton session={session} />
@@ -0,0 +1,571 @@
/**
* @file WorkspaceContext — a workspace's context: Instructions, Memory
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
* workspace home's right column and, `compact`, folded at the bottom of the
* session view's Changes panel. Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
* same form clones related repos beside the workspace (`CloneSourcesForm`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
import { copyText } from "../../lib/clipboard";
import { dragProjectPath } from "../../lib/promptAttach";
import { useDialog } from "../../hooks/useDialog";
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { MarkdownPreview } from "../MarkdownPreview";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { PathPickButton } from "../PathPickButton";
import { CloneSourcesForm } from "./CloneSourcesForm";
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
return <ContextBody key={props.lane.id} {...props} />;
}
function ContextBody({
lane,
onSourcesChange,
sourcesVersion = 0,
compact = false,
}: {
lane: Lane;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
/** Bumped when the folder list changed elsewhere: the lists re-read. */
sourcesVersion?: number;
/** The Changes panel's narrow fold: no card, tighter rows. */
compact?: boolean;
}) {
const { t } = useTranslation("lanes");
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const fileInput = useRef<HTMLInputElement>(null);
const [ctxDrag, setCtxDrag] = useState(false);
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const sourceCtx = useContextMenu<LaneSource>();
useEffect(() => {
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch((err) => live && setError(message(err)));
api.lanes
.memory(lane.id)
.then((r) => live && setMemory(r.files))
.catch(() => live && setMemory([]));
return () => {
live = false;
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (!live) return;
const p = projects.find((x) => x.workspaces.includes(lane.id));
const own = new Set((view?.sources ?? []).map((s) => s.path));
setSuggestions(
!p || !prefs.showUntracked
? []
: p.worktrees
.filter((w) => w.workspace_id === null && !own.has(w.path))
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
.map((w) => w.path)
);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
useEffect(() => {
if (!sourcesVersion) return;
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch(() => undefined);
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
}, [sourcesVersion]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null);
try {
const next = await fn();
setView(next);
onSourcesChange?.(next);
return true;
} catch (err) {
setError(message(err));
return false;
}
};
const addFiles = async (picked: File[]) => {
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
};
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const card = compact
? "border-b border-border/60 px-1 py-2 last:border-b-0"
: "border-b border-border px-4 py-4 last:border-b-0";
const heading = compact
? "text-xs font-medium text-fg-secondary"
: "text-sm font-medium text-fg-primary";
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
return (
<>
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
{error && (
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
{error}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
>
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={compact ? 4 : 6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.memory")}</h3>
<button
type="button"
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section
data-testid="workspace-context-files"
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
// Files dragged in from the OS are added like the + button's pick.
onDragOver={(e) => {
if (!view || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setCtxDrag(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
}}
onDrop={(e) => {
setCtxDrag(false);
if (!view || e.dataTransfer.files.length === 0) return;
e.preventDefault();
void addFiles([...e.dataTransfer.files]);
}}
>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={(e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
void addFiles(picked);
}}
/>
</div>
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
// Dragged onto a prompt box, it is attached as `@<path>`.
draggable
onDragStart={(e) => dragProjectPath(e, f.path)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li
className="flex items-start gap-2 text-xs"
draggable
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
>
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
draggable
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
{folderOpen && (
<CloneSourcesForm
laneId={lane.id}
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
onAdded={(next) => {
setView(next);
onSourcesChange?.(next);
}}
/>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex shrink-0 items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
<div
role="tablist"
aria-label={t("home.memory")}
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
// APG tabs: arrows move (and select), one tab stop.
onKeyDown={(e) => {
const n = memory.length;
const to =
e.key === "ArrowRight"
? (i + 1) % n
: e.key === "ArrowLeft"
? (i - 1 + n) % n
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (to === null) return;
e.preventDefault();
setMemoryTab(to);
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
}}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</button>
))}
</div>
{memory[memoryTab] && (
<div
role="tabpanel"
id="workspace-memory-panel"
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
className="min-h-0 flex-1 overflow-y-auto"
>
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
</>
);
}
+20 -487
View File
@@ -16,40 +16,30 @@
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ArchiveRestore,
ShieldAlert,
FileText,
Folder,
GitBranch,
MoreHorizontal,
Pencil,
ArrowUp,
Plus,
SquareTerminal,
X,
} from "lucide-react";
import {
api,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile,
type LaneSource,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
@@ -58,18 +48,16 @@ import {
import type { Lane, Project, Session } from "../../lib/types";
import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ChatAddMenu } from "../run/ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
import { menuPoint, useContextMenu } from "../ContextMenu";
import {
liveRunFor,
SessionFold,
@@ -87,16 +75,9 @@ export {
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -119,6 +100,7 @@ function HomeBody({
onMenuAction,
onRenamed,
onSourcesChange,
sourcesVersion = 0,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
@@ -127,11 +109,13 @@ function HomeBody({
onRenamed?: () => void;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
/** Bumped when the folder list changed elsewhere (the Changes panel's
* Close repository): the Folder list re-reads. */
sourcesVersion?: number;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
@@ -205,27 +189,9 @@ function HomeBody({
// A Recents row being renamed (its session id) and the draft.
const [renamingId, setRenamingId] = useState<string | null>(null);
const [nameDraft, setNameDraft] = useState("");
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const [ctxDrag, setCtxDrag] = useState(false);
const addFiles = async (picked: File[]) => {
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
};
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
@@ -254,25 +220,6 @@ function HomeBody({
`${runKey}:${version}`
);
useEffect(() => {
let live = true;
setView(null);
setMemory(null);
setEditing(null);
setError(null);
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch((err) => live && setError(message(err)));
api.lanes
.memory(lane.id)
.then((r) => live && setMemory(r.files))
.catch(() => live && setMemory([]));
return () => {
live = false;
};
}, [lane.id]);
// The repository this workspace belongs to (Settings → Projects used to
// show this): read once per visit, from the server's cached project list.
const [project, setProject] = useState<Project | null>(null);
@@ -289,44 +236,6 @@ function HomeBody({
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (!live) return;
const p = projects.find((x) => x.workspaces.includes(lane.id));
const own = new Set((view?.sources ?? []).map((s) => s.path));
setSuggestions(
!p || !prefs.showUntracked
? []
: p.worktrees
.filter((w) => w.workspace_id === null && !own.has(w.path))
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
.map((w) => w.path)
);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null);
try {
const next = await fn();
setView(next);
onSourcesChange?.(next);
return true;
} catch (err) {
setError(message(err));
return false;
}
};
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
const openTerminal = async () => {
@@ -368,6 +277,13 @@ function HomeBody({
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
// Here, not only through the effect: the navigate below unmounts this
// home in the same batch, so the effect never sees the empty prompt.
try {
localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: nothing was kept */
}
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
@@ -449,10 +365,7 @@ function HomeBody({
? t("run:hint.sandboxWarning")
: null;
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const name = workspaceName(lane);
const card = "border-b border-border px-4 py-4 last:border-b-0";
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
@@ -461,7 +374,7 @@ function HomeBody({
data-testid="workspace-home"
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
>
<div className="min-w-0 flex-1 basis-[28rem]">
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
<p className="text-xs text-fg-muted">
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
</p>
@@ -821,363 +734,11 @@ function HomeBody({
</div>
</div>
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className="card w-80 shrink-0 self-start p-0"
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
<button
type="button"
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section
data-testid="workspace-context-files"
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
// Files dragged in from the OS are added like the + button's pick.
onDragOver={(e) => {
if (!view || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setCtxDrag(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
}}
onDrop={(e) => {
setCtxDrag(false);
if (!view || e.dataTransfer.files.length === 0) return;
e.preventDefault();
void addFiles([...e.dataTransfer.files]);
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={(e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
void addFiles(picked);
}}
/>
</div>
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li className="flex items-start gap-2 text-xs">
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex shrink-0 items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
<div
role="tablist"
aria-label={t("home.memory")}
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
// APG tabs: arrows move (and select), one tab stop.
onKeyDown={(e) => {
const n = memory.length;
const to =
e.key === "ArrowRight"
? (i + 1) % n
: e.key === "ArrowLeft"
? (i - 1 + n) % n
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (to === null) return;
e.preventDefault();
setMemoryTab(to);
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
}}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</button>
))}
</div>
{memory[memoryTab] && (
<div
role="tabpanel"
id="workspace-memory-panel"
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
className="min-h-0 flex-1 overflow-y-auto"
>
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
<WorkspaceContext
lane={lane}
onSourcesChange={onSourcesChange}
sourcesVersion={sourcesVersion}
/>
{recentCtx.menu && (
<SessionMenu
@@ -1195,34 +756,6 @@ function HomeBody({
onArchive={() => void archiveSession(recentCtx.menu!.item)}
/>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}
@@ -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,76 @@ 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();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
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,61 @@
/**
* @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.
* @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() } },
}));
import { api, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm } from "../CloneSourcesForm";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => {
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
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, {
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
});
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
expect(b).not.toBeNull();
return b!;
});
// Inline at the end of the first selected line, never a tooltip over the
// line above it.
expect(chip.closest(".cm-tooltip")).toBeNull();
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
// A tooltip above the last selected line, never inline at a line's end
// (a long line pushed it out of view).
expect(chip.classList.contains("cm-tooltip")).toBe(true);
expect(chip.closest(".cm-line")).toBeNull();
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
@@ -0,0 +1,434 @@
/**
* @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) => (session ? { lane, session } : lane),
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
api: {
lanes: {
list: 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(),
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("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();
});
});
@@ -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",
]);
});
});
@@ -0,0 +1,109 @@
/**
* @file InterruptedSessionsPrompt.test.tsx
* @description The "resume interrupted sessions?" dialog: it opens only when
* the server lists some, resumes only the ticked ones (never a bypass mode
* carried over), and either answer dismisses the list — but a resume that
* failed stays offered.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { InterruptedRun } from "../../../lib/api";
vi.mock("../startSession", () => ({
startInFreeTabDetailed: vi.fn(async (laneId: number) => ({
runId: `agenthub-lane-${laneId}-1`,
laneId,
held: false,
})),
}));
vi.mock("../../../lib/api", () => ({
api: {
run: {
interrupted: vi.fn(),
dismissInterrupted: vi.fn(async () => ({ dismissed: 0 })),
},
},
}));
import { api } from "../../../lib/api";
import { startInFreeTabDetailed } from "../startSession";
import { InterruptedSessionsPrompt } from "../InterruptedSessionsPrompt";
const row = (session_id: string, lane_id: number, extra: Partial<InterruptedRun> = {}) =>
({
id: `agenthub-lane-${lane_id}`,
session_id,
lane_id,
tool: "claude",
cwd: "/r",
model: null,
permission_mode: null,
effort: null,
prompt_preview: `prompt ${session_id}`,
started_at: "2026-10-10T00:00:00Z",
ended_at: "2026-10-10T01:00:00Z",
lane_title: `ws-${lane_id}`,
lane_cwd: "/r",
session_name: null,
...extra,
}) as InterruptedRun;
const list = (items: InterruptedRun[]) =>
vi.mocked(api.run.interrupted).mockResolvedValue({ items });
beforeEach(() => vi.clearAllMocks());
const renderIt = () =>
render(
<MemoryRouter>
<InterruptedSessionsPrompt />
</MemoryRouter>
);
describe("InterruptedSessionsPrompt", () => {
it("shows nothing when no session was interrupted", async () => {
list([]);
renderIt();
await waitFor(() => expect(api.run.interrupted).toHaveBeenCalled());
expect(screen.queryByRole("dialog")).toBeNull();
});
it("resumes only the ticked sessions, then dismisses the list", async () => {
list([row("s-a", 1, { permission_mode: "bypassPermissions", model: "opus" }), row("s-b", 2)]);
const user = userEvent.setup();
renderIt();
await screen.findByRole("dialog");
await user.click(screen.getByRole("checkbox", { name: /ws-2/ }));
await user.click(screen.getByRole("button", { name: /resume 1/i }));
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a", "s-b"]));
expect(startInFreeTabDetailed).toHaveBeenCalledTimes(1);
// The model goes back; a bypass mode is never carried over.
expect(startInFreeTabDetailed).toHaveBeenCalledWith(1, {
resumeSessionId: "s-a",
model: "opus",
});
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("resumes nothing on Don't resume, and keeps a session whose resume failed", async () => {
list([row("s-a", 1)]);
const user = userEvent.setup();
const { unmount } = renderIt();
await user.click(await screen.findByText("Don't resume"));
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a"]));
expect(startInFreeTabDetailed).not.toHaveBeenCalled();
unmount();
vi.clearAllMocks();
list([row("s-a", 1), row("s-b", 2)]);
vi.mocked(startInFreeTabDetailed).mockRejectedValueOnce(new Error("boom"));
renderIt();
await user.click(await screen.findByRole("button", { name: /resume 2/i }));
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-b"]));
expect(screen.getByRole("checkbox", { name: /ws-1/ })).toBeInTheDocument();
});
});
@@ -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 () => {
@@ -545,6 +568,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,
@@ -1687,6 +1798,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(
@@ -14,7 +14,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
@@ -241,6 +241,27 @@ describe("WorkspaceHome", () => {
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("drops the sent prompt's draft even though opening the session unmounts the home", async () => {
// 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} />;
}
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Routes>
<Route path="*" element={<HomeUntilRun />} />
</Routes>
</MemoryRouter>
);
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "sent once");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(screen.queryByTestId("workspace-home-start")).toBeNull());
expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull();
});
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
+4 -2
View File
@@ -9,7 +9,7 @@
import i18n from "../../i18n";
import { api, type RunHandle } from "../../lib/api";
import { MAX_SLOTS } from "../../lib/paneLayout";
import { MAX_SLOTS, slotOfKey } from "../../lib/paneLayout";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
@@ -58,7 +58,9 @@ export async function startInFreeTabDetailed(
/** The first pane slot with no live run, or null when all ten are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
runs
.filter((r) => r.laneId === laneId && r.status === "running")
.map((r) => slotOfKey(r.slot ?? 0))
);
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
return null;
@@ -171,8 +171,16 @@ export function OnboardingTour() {
style = { left: right + 16, top };
} else if (rect.bottom + 180 < window.innerHeight) {
style = { left, top: rect.bottom + 12 };
} else if (rect.top > 200) {
style = { left, top: rect.top - 12, transform: "translateY(-100%)" };
} else {
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
// A target filling the screen (the live session): no room above or
// below, so the card sits inside its bottom-right corner.
style = {
left: Math.min(Math.max(12, rect.right - W - 16), window.innerWidth - W - 12),
top: Math.min(rect.bottom, window.innerHeight) - 16,
transform: "translateY(-100%)",
};
}
}
+135 -22
View File
@@ -6,18 +6,26 @@
* on the right — Claude's Uploads dialog). Content comes only through routes
* that already guard it: a file inside the workspace through
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
* `~/.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`, 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 } from "react";
import { useEffect, 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 { 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";
/** Where a Chat pane's files can be read from. */
@@ -31,9 +39,17 @@ export interface PreviewScope {
}
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
// An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js).
const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i;
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
const DROP_RE =
/[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
/** A dropped text file shown at most this long. */
const DROP_TEXT_MAX = 200_000;
export const baseName = (p: string) => p.split("/").pop() || p;
/** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */
const slashed = (p: string) => p.replace(/\\/g, "/");
export const baseName = (p: string) => p.split(/[\\/]/).pop() || p;
/** `report.v2.xlsx` → `XLSX`; none → "". */
export const extOf = (p: string) => {
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
@@ -52,37 +68,62 @@ type Content =
function useContent(path: string, scope: PreviewScope): Content {
const [content, setContent] = useState<Content>({ kind: "loading" });
useEffect(() => {
const drop = DROP_RE.exec(path);
if (drop) {
setContent(
IMAGE_RE.test(path)
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
: { kind: "none" }
);
return;
}
let live = true;
const show = (p: Promise<Content>) => {
setContent({ kind: "loading" });
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
};
const base = scope.cwd.replace(/\/+$/, "");
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
// Claude Code's own files (memory): the config pages' guarded read.
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
const drop = DROP_RE.exec(path);
if (drop) {
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
else if (DROP_TEXT_RE.test(path))
show(
fetch(src).then(async (r) => {
if (!r.ok) throw new Error(String(r.status));
const text = await r.text();
return {
kind: "text",
text: text.slice(0, DROP_TEXT_MAX),
truncated: text.length > DROP_TEXT_MAX,
};
})
);
else setContent({ kind: "none" });
return () => {
live = false;
};
}
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
// Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show(
api.ccConfig
.file(path)
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
);
} else if (IMAGE_RE.test(file) && scope.sessionId) {
// 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;
};
}
const rel = path.slice(base.length + 1);
const rel = file.slice(base.length + 1);
const target =
base === scope.laneCwd.replace(/\/+$/, "")
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) {
@@ -107,7 +148,11 @@ function useContent(path: string, scope: PreviewScope): Content {
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
const { t } = useTranslation("run");
const content = useContent(path, scope);
const loaded = useContent(path, scope);
// 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") {
return (
<div className="flex flex-1 items-center justify-center">
@@ -121,6 +166,7 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
<img
src={content.src}
alt={baseName(path)}
onError={() => setBroken(content.src)}
className="max-h-full max-w-full object-contain"
/>
</div>
@@ -204,13 +250,19 @@ export function FilePreview({
export function FileListModal({
title,
paths,
label = displayName,
scope,
onClose,
onDragItem,
}: {
title: string;
paths: string[];
/** A row's text; the file name by default. */
label?: (path: string) => string;
scope: PreviewScope;
onClose: () => void;
/** Rows drag into the prompt box; the dialog then closes, out of the way. */
onDragItem?: (e: DragEvent, path: string) => void;
}) {
const [picked, setPicked] = useState(paths[0] ?? "");
const panelRef = useRef<HTMLDivElement>(null);
@@ -239,12 +291,18 @@ export function FileListModal({
<button
type="button"
onClick={() => setPicked(p)}
draggable={!!onDragItem}
onDragStart={(e) => {
onDragItem?.(e, p);
// Not in dragstart itself: removing the source there cancels the drag.
if (onDragItem) window.setTimeout(onClose, 0);
}}
title={p}
aria-current={p === picked}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
>
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 truncate">{displayName(p)}</span>
<span className="min-w-0 truncate">{label(p)}</span>
</button>
</li>
))}
@@ -256,3 +314,58 @@ export function FileListModal({
document.body
);
}
/** The session's skills in the list dialog, each one's SKILL.md previewed. */
export function SkillListModal({
title,
names,
scope,
onClose,
draggable = false,
}: {
title: string;
names: string[];
scope: PreviewScope;
onClose: () => void;
/** Rows drag into the prompt box as `/name`. */
draggable?: boolean;
}) {
const [files, setFiles] = useState<Map<string, string> | null>(null);
useEffect(() => {
let live = true;
void Promise.all([
api.ccConfig.skills(undefined, scope.cwd).catch(() => ({ items: [] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
]).then(([sk, pl]) => {
const found = new Map<string, string>();
const add = (name: string, file: string | undefined) => {
if (file && !found.has(name)) found.set(name, file);
};
for (const p of pl.plugins) {
const items = p.contributes?.items;
for (const it of [...(items?.skills ?? []), ...(items?.commands ?? [])])
add(`${p.name}:${it.name}`, it.file);
}
// A personal skill wins over a project one of the same name, as in Claude Code.
for (const s of sk.items) if (s.scope === "user") add(s.name, s.file);
for (const s of sk.items) add(s.name, s.file);
if (live) setFiles(found);
});
return () => {
live = false;
};
}, [scope.cwd]);
if (!files) return null;
// A built-in skill has no file: its name stands in and previews as nothing.
const byPath = new Map(names.map((n) => [files.get(n) ?? n, n]));
return (
<FileListModal
title={title}
paths={[...byPath.keys()]}
label={(p) => byPath.get(p) ?? displayName(p)}
scope={scope}
onClose={onClose}
onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined}
/>
);
}
+99 -19
View File
@@ -1,20 +1,32 @@
/**
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane shown alone, laid out like
* @description The right-hand panel of a Chat pane, laid out like
* Claude Desktop's activity panel: the session's Progress (its
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one — how many
* steps are done of how many, a bar, and the step in progress highlighted), the Outputs
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
* this session" — the files attached to its prompts, the files it read and the
* memory files it used (a click opens the list with a preview), its skills
* and MCP servers (a click opens the Claude config page). Read from
* memory files it used and its skills (a click opens the list with a
* preview — a skill's SKILL.md), and its MCP servers (a click opens the
* Claude config page). Every row but MCP drags into the prompt box: a file
* as an attachment, a group as all of its files, a lone skill as `/name`.
* Read from
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
* uses for the collapsed button's count.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType, 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";
import {
BookOpen,
Check,
@@ -30,7 +42,10 @@ 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";
const POLL_MS = 5000;
const EMPTY: SessionOutline = {
@@ -43,29 +58,48 @@ 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;
}
export type OutlineList = "uploads" | "files" | "memory";
export type OutlineList = "uploads" | "files" | "memory" | "skills";
/** A listed file as a prompt attachment: an upload by its bare path, as the
* composer attaches one; anything else as an `@` mention. */
export function outlineAttachment(path: string, upload = false): Attachment {
return { path: upload ? path : `@${path}`, name: displayName(path) };
}
const dragFiles =
(paths: string[], upload = false) =>
(e: DragEvent) =>
dragAttachments(
e,
paths.map((p) => outlineAttachment(p, upload))
);
function fileIcon(path: string) {
const ext = extOf(path).toLowerCase();
@@ -88,7 +122,8 @@ function Section({
children,
}: {
title: string;
count?: number;
/** Beside the title: a count, or a "3/7". */
count?: number | string;
action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */
grow?: boolean;
@@ -136,12 +171,15 @@ function UsedRow({
label,
names,
onClick,
onDragStart,
to,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
names: string[];
onClick?: () => void;
/** The row drags into the prompt box. */
onDragStart?: (e: DragEvent) => void;
to?: string;
}) {
const shown = names.slice(0, 2).join(", ");
@@ -161,7 +199,14 @@ function UsedRow({
{inner}
</Link>
) : (
<button type="button" title={title} onClick={onClick} className={rowClass}>
<button
type="button"
title={title}
onClick={onClick}
draggable={!!onDragStart}
onDragStart={onDragStart}
className={rowClass}
>
{inner}
</button>
);
@@ -181,6 +226,8 @@ export function ChatOutline({
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const href = useSettingsHref();
const doneSteps = o.progress.filter((p) => p.status === "completed").length;
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
@@ -198,21 +245,47 @@ export function ChatOutline({
<aside
data-testid="chat-outline"
aria-label={t("chat.outline.panel")}
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
// Over the conversation in a narrow pane (the container is PaneChat's
// root; `--chat-area` keeps the prompt box below it uncovered),
// beside it once the pane has room, as wide as PaneChat's splitter says.
className="absolute right-3 top-3 bottom-[calc(100%-var(--chat-area,100%)+0.75rem)] z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
>
{o.progress.length > 0 && (
<Section title={t("chat.outline.progress")} action={close}>
<Section
title={t("chat.outline.progress")}
count={`${doneSteps}/${o.progress.length}`}
action={close}
>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={o.progress.length}
aria-valuenow={doneSteps}
aria-label={t("chat.outline.progress")}
className="mx-1.5 mb-1.5 h-1 overflow-hidden rounded-full bg-surface-3"
>
<div
className="h-full rounded-full bg-accent-solid transition-[width] duration-500"
style={{ width: `${(doneSteps / o.progress.length) * 100}%` }}
/>
</div>
{o.progress.map((task) => {
const done = task.status === "completed";
const now = task.status === "in_progress";
return (
<div key={task.id} className={rowClass} title={task.subject}>
<div
key={task.id}
className={`${rowClass} ${now ? "bg-accent/10 font-medium" : ""}`}
title={task.subject}
aria-current={now ? "step" : undefined}
>
{done ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
<Check className="h-3 w-3" />
</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
@@ -241,6 +314,8 @@ export function ChatOutline({
title={p}
aria-label={`${extOf(p)}: ${stem(p)}`}
onClick={() => onOpenFile(p)}
draggable
onDragStart={dragFiles([p])}
className={rowClass}
>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
@@ -261,6 +336,7 @@ export function ChatOutline({
label={t("chat.outline.uploads")}
names={o.uploads.map(displayName)}
onClick={() => onOpenList("uploads")}
onDragStart={dragFiles(o.uploads, true)}
/>
)}
{o.files.length > 0 && (
@@ -269,6 +345,7 @@ export function ChatOutline({
label={t("chat.outline.files")}
names={o.files.map(displayName)}
onClick={() => onOpenList("files")}
onDragStart={dragFiles(o.files)}
/>
)}
{o.memory.length > 0 && (
@@ -277,6 +354,7 @@ export function ChatOutline({
label={t("chat.outline.memory")}
names={o.memory.map(stem)}
onClick={() => onOpenList("memory")}
onDragStart={dragFiles(o.memory)}
/>
)}
{o.skills.length > 0 && (
@@ -284,7 +362,9 @@ export function ChatOutline({
icon={BookOpen}
label={t("chat.skills")}
names={o.skills}
to="/settings/agents/claude-code?tab=skills"
onClick={() => onOpenList("skills")}
// One command per prompt: several are dragged one by one from the list.
onDragStart={o.skills.length === 1 ? (e) => dragSkill(e, o.skills[0]!) : undefined}
/>
)}
{o.mcp.length > 0 && (
@@ -292,7 +372,7 @@ export function ChatOutline({
icon={Plug}
label={t("chat.outline.connectors")}
names={o.mcp}
to="/settings/agents/claude-code?tab=mcp"
to={href("agents/claude-code?tab=mcp")}
/>
)}
</Section>
+183 -40
View File
@@ -8,8 +8,11 @@
* prompt it started on, else its tool and number; a combo tab shows every
* pane's icon and name. A tab carries a state dot (waiting for you / error /
* running — a combo shows the most urgent, with screen-reader text). A tab's
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
* X stops every session in it AND archives them (`endRuns`,
* `DELETE /api/run/:id?archive=1`); the moon beside it (and the tab menu's
* Sleep) stops its AI sessions but keeps them listed, to resume later. Either
* asks first when one is an AI tool, since it may be mid-turn; a shell closes
* at once. Arrow
* keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
@@ -25,8 +28,15 @@
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* one, up to four panes). A pane drag held over another tab for `SPRING_MS` shows that tab
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
* any of its panes' edges; near either end of an overflowing bar a drag
* scrolls it. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name. Settings
* opened on a workspace is one more tab at the bar's end (`settings`, kept
* until its X), shown instead of the panes while it is the active one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -41,18 +51,26 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
import { House, Moon, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import { useSettingsHref } from "../../lib/settingsLink";
import type { RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { canSleep, endRuns } from "../../lib/stopSession";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import {
PANE_DRAG_TYPE,
RunIcon,
paneDragSlot,
setDragChip,
startPaneDrag,
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
@@ -62,6 +80,7 @@ import {
joinGroup,
leaves,
reorderGroups,
slotOfKey,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -77,7 +96,7 @@ export function runName(
): string {
if (r.tool === "shell") return terminal;
const prompt = r.promptPreview?.trim().split("\n")[0];
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
return session?.name || prompt || `${toolLabel} #${slotOfKey(r.slot ?? 0) + 1}`;
}
export type RunState = "awaiting" | "error" | "running";
@@ -149,6 +168,14 @@ interface Tab {
const URGENCY: RunState[] = ["awaiting", "error", "running"];
/** How long a pane drag rests on a tab before that tab is shown. */
export const SPRING_MS = 600;
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
const EDGE_SCROLL_PX = 40;
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
export function ConsoleTabs({
lane,
tabs: runs,
@@ -160,10 +187,14 @@ export function ConsoleTabs({
actions,
sessions,
onRenamed,
workspaceOf,
settings,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
/** Tabs from every workspace: the name of the workspace a run belongs to. */
workspaceOf?: (r: RunHandle) => string;
/** The tabs: groups of panes, the active one on screen. */
groups: PaneGroups;
/** Reorders the tabs or takes a pane out into a tab of its own. */
@@ -181,9 +212,12 @@ export function ConsoleTabs({
onHome?: () => void;
/** Right end of the bar (the single pane's split menus). */
actions?: ReactNode;
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const settingsHref = useSettingsHref();
const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false);
// The tab being renamed (its run id) and the draft name.
@@ -240,8 +274,59 @@ export function ConsoleTabs({
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
};
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
const stopSpring = () => {
if (spring.current) clearTimeout(spring.current.timer);
spring.current = null;
};
useEffect(() => stopSpring, []);
const hoveredIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left && x <= b.right;
});
};
const springAt = (e: DragEvent) => {
const slot = paneDragSlot();
const i = hoveredIndexAt(e.clientX);
const tab = tabs[i];
// Only a pane drag, onto a tab not on screen that does not hold it.
const target =
slot !== null &&
tab &&
!settings?.active &&
tab.index !== groups.active &&
!tab.slots.includes(slot)
? i
: null;
if ((spring.current?.at ?? null) === target) return;
stopSpring();
if (target === null || !tab) return;
const index = tab.index;
spring.current = {
at: target,
timer: setTimeout(() => {
spring.current = null;
if (paneDragSlot() === null) return;
onGroupsChange((g) =>
g.active === index || !g.groups[index] ? g : { ...g, active: index }
);
}, SPRING_MS),
};
};
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
const edgeScroll = (x: number) => {
const list = listRef.current;
if (!list || list.scrollWidth <= list.clientWidth) return;
const b = list.getBoundingClientRect();
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
};
const endDrag = () => {
dragged.current = [];
stopSpring();
setDropAt(null);
setJoinAt(null);
};
@@ -250,6 +335,8 @@ export function ConsoleTabs({
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
edgeScroll(e.clientX);
springAt(e);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
@@ -257,6 +344,7 @@ export function ConsoleTabs({
},
onDragLeave: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
stopSpring();
setDropAt(null);
setJoinAt(null);
},
@@ -325,7 +413,10 @@ export function ConsoleTabs({
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
const toolLabel = (r: RunHandle) =>
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
const label = (r: RunHandle) => {
const name = runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
return workspaceOf ? `${workspaceOf(r)} · ${name}` : name;
};
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
// A combo's dot is its most urgent AI pane's; a shell has none.
const tabState = (tab: Tab): RunState | null => {
@@ -344,30 +435,10 @@ export function ConsoleTabs({
}
};
// An AI session may be mid-turn: stopping a tab holding one asks first. A
// shell holds no agent.
const close = async (tab: Tab) => {
if (
tab.runs.some((r) => r.tool !== "shell") &&
!(await confirmDialog({
title:
tab.runs.length > 1
? t("tabs.stopGroupTitle", { count: tab.runs.length })
: t("tabs.stopTitle", { name: label(tab.focus) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
for (const r of tab.runs) {
try {
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
toast.error(err);
}
}
// Stop archives the tab's sessions; Sleep keeps them listed to resume
// (`endRuns` asks first for an AI pane, which may be mid-turn).
const close = async (tab: Tab, sleep = false) => {
for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
};
const startRename = (r: RunHandle) => {
@@ -388,8 +459,9 @@ export function ConsoleTabs({
};
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
const keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
const last = tabs.length - 1;
const last = keys.length - 1;
const to =
e.key === "ArrowRight"
? i === last
@@ -404,11 +476,14 @@ export function ConsoleTabs({
: e.key === "End"
? last
: null;
if (to === null || !tabs[to]) return;
const key = to === null ? undefined : keys[to];
if (key === undefined) return;
e.preventDefault();
tabRefs.current.get(tabs[to].key)?.focus();
tabRefs.current.get(key)?.focus();
};
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
const tabStop = settings?.active
? SETTINGS_KEY
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
// A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (tab: Tab) => unknown) => () => {
@@ -447,7 +522,7 @@ export function ConsoleTabs({
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
>
{tabs.map((tab, i) => {
const active = tab.index === groups.active;
const active = !settings?.active && tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const state = tabState(tab);
if (single && renaming === single.id) {
@@ -481,10 +556,11 @@ export function ConsoleTabs({
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
if (single) startPaneDrag(e, single.slot ?? 0, label(single));
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
setDragChip(e, tabLabel(tab));
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
@@ -545,6 +621,20 @@ export function ConsoleTabs({
<span className="truncate">{tabLabel(tab)}</span>
{state && <RunDot state={state} />}
</button>
{tab.runs.some(canSleep) && (
<button
type="button"
data-testid={`console-tab-sleep-${tab.key}`}
onClick={() => void close(tab, true)}
aria-label={t("tabs.sleep")}
title={t("tabs.sleep")}
className={`shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<Moon className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
data-testid={`console-tab-close-${tab.key}`}
@@ -560,6 +650,48 @@ export function ConsoleTabs({
</div>
);
})}
{settings && (
<div
className={`group relative flex shrink-0 items-center rounded-t-md ${
settings.active
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
>
{settings.active && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
<button
type="button"
role="tab"
aria-selected={settings.active}
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(SETTINGS_KEY, el);
else tabRefs.current.delete(SETTINGS_KEY);
}}
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
data-testid="console-tab-settings"
onClick={settings.onOpen}
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
>
<Settings className="h-4 w-4 shrink-0" />
{t("tabs.settings")}
</button>
<button
type="button"
data-testid="console-tab-settings-close"
onClick={settings.onClose}
aria-label={t("tabs.closeSettings")}
title={t("tabs.closeSettings")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
settings.active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div ref={menuRef} className="relative flex items-center px-1.5">
<button
@@ -609,7 +741,10 @@ export function ConsoleTabs({
type="button"
role="menuitem"
className={`${item} !text-fg-muted`}
onClick={() => navigate("/settings/agents")}
onClick={() => {
setMenuOpen(false);
navigate(settingsHref("agents"));
}}
>
<Settings2 className="h-4 w-4" />
{t("tabs.agentSettings")}
@@ -647,6 +782,14 @@ export function ConsoleTabs({
</>
)}
<MenuSeparator />
{ctxTab.runs.some(canSleep) && (
<MenuItem
testId="console-tab-menu-sleep"
onSelect={pickTab((tab) => void close(tab, true))}
>
{t("tabs.sleep")}
</MenuItem>
)}
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")}
</MenuItem>
+15 -9
View File
@@ -20,7 +20,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Plus } from "lucide-react";
import { api } from "../../lib/api";
@@ -51,8 +51,9 @@ export interface LaneConsolePaneProps {
onRunExit?: () => void;
/** The terminal takes the keyboard when it appears (default). */
autoFocus?: boolean;
/** The only pane shown: its Chat view gets the right panel. */
solo?: boolean;
/** The pane spans the grid's full width or height: its Chat view's right
* panel starts open (collapsed in a quarter-sized pane). */
panelOpen?: boolean;
}
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
@@ -71,7 +72,7 @@ export function LaneConsolePane({
onNewSession,
onRunExit,
autoFocus,
solo = false,
panelOpen = false,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const [handle, setHandle] = useState<RunHandle | null>(null);
@@ -88,6 +89,13 @@ export function LaneConsolePane({
: null;
const chat = useSessionChat(chatSession);
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
// Stable, so the memoized terminal is not re-rendered for a new closure.
const onRunExitRef = useRef(onRunExit);
onRunExitRef.current = onRunExit;
const onTerminalExit = useCallback(() => {
setHandle(null);
onRunExitRef.current?.();
}, []);
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -201,7 +209,7 @@ export function LaneConsolePane({
cwd={handle.cwd || currentLane?.cwd || ""}
laneId={laneId}
laneCwd={currentLane?.cwd || ""}
outline={solo}
panelOpen={panelOpen}
/>
</div>
)}
@@ -209,13 +217,11 @@ export function LaneConsolePane({
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
onExit={() => {
setHandle(null);
onRunExit?.();
}}
onExit={onTerminalExit}
/>
</div>
)}
+452 -71
View File
@@ -12,37 +12,78 @@
* `/model <id>` / `/effort <level>` — and the permission mode, which only
* Shift+Tab changes in a running session, so the server steps it and reads
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
* Shown alone (`outline`), it also has Claude Desktop's activity panel
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
* file button with the output count until clicked (remembered per browser);
* a file clicked there opens its preview in the panel's place
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
* permission or trust prompt, Codex's approval — is read off the terminal
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
* a field in the card, whose text is typed there as the answer
* (`answerRunMenu`).
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
* outputs, what the session used), open at first in a pane spanning the
* grid's full width or height (`panelOpen`) once the session has an output,
* else collapsed into a file button with the output count; the user's own
* open/close is kept per session for the tab (sessionStorage), so the chat
* leaving for the terminal and back keeps it; it sits beside the
* conversation when the pane is wide and over it when narrow (a CSS container
* query on the pane, not the window), dragged wider or narrower by its
* splitter there (a fraction of the pane, remembered per browser). A file
* clicked there opens its preview
* in the panel's place
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
* the workspace's folder —, a group
* of files a list with a preview (`FileListModal`). A file in a turn's
* of files or the used skills a list with a preview (`FileListModal`,
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
* conversation to its end and keeps it there (`follow`), so the message and
* the step running after it come into sight on their own. While the session's
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
* Stop in Send's place: Esc pressed in the pane (`interruptRun`). While the
* CLI asks the user something (the main agent awaiting input on a
* Notification — a permission prompt), the chat's live line says so and
* offers the terminal (`showTerminal`); before the session has started (no
* row yet: a trust prompt, say, answered only there) anything typed into the
* pane is confirmed first, so an Enter does not pick that prompt's
* preselected "No, exit" unasked. The box shows how full the context
* is (the newest reply's prompt size against the model's window,
* `contextWindow`), and a hidden browser tab is marked while the session
* waits on the user or once its turn ended (`lib/tabBadge.ts`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square } from "lucide-react";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { resolveIn } from "../../lib/resolvePath";
import { Select } from "../Select";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import { Divider, showTerminal } from "./PaneGrid";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format";
import { agentAwaitingReason } from "../../lib/types";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
@@ -53,7 +94,69 @@ const MODES: [PermissionMode, string][] = [
["bypassPermissions", "Bypass"],
];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
const SIDE_KEY = "agenthub.chat.panelWidth";
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
const readOutlineChoice = (sessionId: string) => {
const v = sessionStorage.getItem(outlineKey(sessionId));
return v === null ? null : v === "1";
};
const storedSide = () => {
const f = Number(localStorage.getItem(SIDE_KEY));
return f ? clampSide(f) : null;
};
/** The model's context window. Claude's 200K models are the 4.5 line and
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
export function contextWindow(model: string | null): number | null {
if (!model?.startsWith("claude-")) return null;
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
? 200_000
: 1_000_000;
}
/** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
const { t } = useTranslation("run");
const size = contextWindow(model);
if (!size || !tokens) return null;
const pct = Math.min(100, Math.round((tokens / size) * 100));
const tone =
pct >= 90 ? "text-status-danger" : pct >= 75 ? "text-status-warning" : "text-fg-muted";
const ring = 2 * Math.PI * 6;
return (
<span
data-testid="pane-chat-context"
title={t("chat.contextTitle", { used: fmt(tokens), size: fmt(size) })}
className={`flex items-center gap-1 px-1 text-xs tabular-nums ${tone}`}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 -rotate-90" aria-hidden>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={`${(pct / 100) * ring} ${ring}`}
/>
</svg>
{pct}%
</span>
);
}
/** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
@@ -103,13 +206,118 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
);
}
export function PaneChat({
/** The menu the CLI asks with, as buttons: a pick presses its key in the
* pane; its "Type something" row opens a field right there instead —
* pressing that row's digit again would type the digit into the CLI's own. */
export function PaneMenuCard({
menu,
onChoose,
onAnswer,
onCancel,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onAnswer: (text: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation("run");
const [typing, setTyping] = useState(false);
const [draft, setDraft] = useState("");
const submit = () => draft.trim() && onAnswer(draft);
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
{menu.question && (
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
)}
<div className="flex flex-col gap-1">
{menu.options.map((o, i) =>
typing && i === menu.typeAt ? (
<div
key={`${i}-type`}
className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1"
>
<span className="w-4 flex-shrink-0 font-mono text-xs text-fg-muted">{o.n}</span>
<input
autoFocus
data-testid="pane-chat-menu-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
submit();
} else if (e.key === "Escape") setTyping(false);
}}
placeholder={t("chat.menu.placeholder")}
aria-label={t("chat.menu.placeholder")}
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
/>
<button
type="button"
data-testid="pane-chat-menu-send"
onClick={submit}
disabled={!draft.trim()}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
key={`${i}-${o.label}`}
type="button"
onClick={() => (i === menu.typeAt ? setTyping(true) : onChoose(i))}
className={`flex items-start gap-2 rounded-lg border px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 ${
i === menu.cursor ? "border-accent/50 bg-surface-2" : "border-border"
}`}
>
<span className="mt-0.5 w-4 flex-shrink-0 font-mono text-xs text-fg-muted">
{o.checked === null ? (
(o.n ?? "↵")
) : o.checked ? (
<SquareCheck className="h-3.5 w-3.5 text-accent" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 break-words">
{o.label}
{o.hint && <span className="block text-xs text-fg-muted">{o.hint}</span>}
</span>
</button>
)
)}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
</div>
);
}
/** Memoized: a lane_update re-renders the Workspace's pane tree, and every
* prop here is a primitive or the pane's own run handle — nothing in a
* frame about another lane reaches the chat. */
export const PaneChat = memo(function PaneChat({
run,
sessionId,
cwd,
laneId = null,
laneCwd = "",
outline = false,
panelOpen = false,
}: {
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
sessionId: string;
@@ -117,41 +325,81 @@ export function PaneChat({
/** The workspace and its folder: a file under `cwd` is previewed through it. */
laneId?: number | null;
laneCwd?: string;
/** The pane is shown alone: room for the right panel. */
outline?: boolean;
/** The right panel starts open: the pane spans the grid's width or height. */
panelOpen?: boolean;
}) {
const { t } = useTranslation("run");
const runId = run.id;
const claude = run.tool === "claude";
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
const [uploading, setUploading] = useState(false);
const [follow, setFollow] = useState(0);
const [model, setModel] = useState(run.model ?? "");
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
const pickFiles = useRef<(() => void) | null>(null);
// Collapsed until asked for, like Claude Desktop's activity panel.
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
// The user's own open/close, per session for this tab: it outlives the chat
// leaving for the terminal (an unmount). Unset, the default rules.
const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId));
// A split or a close changes the pane's shape: its default comes back.
const shape = useRef(panelOpen);
useEffect(() => {
if (shape.current === panelOpen) return;
shape.current = panelOpen;
sessionStorage.removeItem(outlineKey(sessionId));
setOutlineChoice(null);
}, [panelOpen, sessionId]);
const openOutline = (open: boolean) => {
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
setOutlineOpen(open);
sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
setOutlineChoice(open);
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
// The side panel's width as a fraction of the pane: the dragged one, else
// a default per content (a preview wants more room than the panel).
const [dragged, setDragged] = useState(storedSide);
const side = dragged ?? (preview ? 0.45 : 0.3);
const rootRef = useRef<HTMLDivElement>(null);
// The conversation's height, so a panel laid over a narrow pane stops above
// the prompt box instead of covering it.
const areaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const area = areaRef.current;
if (!area || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() =>
rootRef.current?.style.setProperty("--chat-area", `${area.offsetHeight}px`)
);
ro.observe(area);
return () => ro.disconnect();
}, []);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
// Kept mounted in a hidden workspace or tab: its pollers skip until shown.
const onScreen = useOnScreen(rootRef);
const activity = useSessionOutline(sessionId, onScreen);
// Open by default only in a spanning pane, and only once there is an output.
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images);
// The main agent is "working" from a prompt's submit to its Stop hook.
// The main agent is "working" from a prompt's submit to its Stop hook, and
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false);
const [waiting, setWaiting] = useState(false);
// Stop presses, for the conversation's live line (issue #74).
const [stops, setStops] = useState(0);
const readWorking = useCallback(
() =>
api.agents
.list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its
// status filter ignored: check the status here.
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
.then((r) => {
const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working"));
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
[sessionId]
);
@@ -163,22 +411,104 @@ export function PaneChat({
// While Stop shows, also poll: a missed agent_updated (the hook reached
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working ? 5000 : undefined,
intervalMs: working || waiting ? 5000 : undefined,
match: (msg) =>
msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// The menu the terminal underneath shows now. AskUserQuestion fires no
// Notification, so this — not `waiting` alone — says the CLI asks.
// ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile.
const [menu, setMenu] = useState<PaneMenu | null>(null);
useEffect(() => {
const read = () =>
setMenu((prev) => {
const next = readRunMenu(runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
read();
// Nobody can answer a menu in a hidden tab or pane: no screen scan then.
const timer = window.setInterval(() => {
if (isShown(onScreen)) read();
}, 700);
return () => window.clearInterval(timer);
}, [runId, onScreen]);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
if (menu && chooseRunOption(runId, menu, index)) setMenu(null);
else toast.error(t("chat.notSent"));
};
const answer = (reply: string) => {
if (menu && answerRunMenu(runId, menu, reply)) {
setMenu(null);
setFollow((n) => n + 1);
} else toast.error(t("chat.notSent"));
};
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, asking: false });
useEffect(() => {
if (asking) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working, asking };
}, [runId, working, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
model: null,
});
const onContext = useCallback(
(tokens: number, model: string | null) =>
setContext((prev) =>
prev.tokens === tokens && prev.model === model ? prev : { tokens, model }
),
[]
);
// Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
const path = resolveIn(cwd, named);
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path);
},
[laneId, cwd]
);
// Before the session starts the CLI may be asking in the terminal (trust
// this folder? with "No, exit" preselected): an Enter from here answers it.
// Asked, not refused: whether the session row appears right after the
// answer depends on Claude Code firing a hook then, and a hard lock would
// strand the chat if it does not.
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would.
const command = (line: string) => {
const command = async (line: string) => {
if (
notStarted &&
!(await confirmDialog({
title: t("chat.notStartedTitle"),
message: t("chat.notStarted"),
confirmLabel: t("chat.sendAnyway"),
}))
)
return false;
const ok = submitToRun(runId, line);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = () => {
const send = async () => {
if (!text || uploading) return;
if (!command(text)) return;
if (!(await command(text))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
@@ -186,29 +516,34 @@ export function PaneChat({
};
return (
<div className="relative flex min-h-0 flex-1">
<div
ref={rootRef}
data-prompt-drop
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
>
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
<div className="relative min-h-0 flex-1 overflow-hidden">
<div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
<ConversationView
sessionId={sessionId}
fill
live
follow={follow}
onOpenFile={(path) => {
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview, when it has room for one.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
if (outline) setPreview(path);
}}
waiting={asking}
stopped={stops}
onOpenTerminal={openTerminal}
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={openFile}
/>
{outline && !outlineOpen && !preview && (
{!outlineOpen && !preview && (
<button
type="button"
data-testid="chat-outline-show"
onClick={() => openOutline(true)}
title={t("chat.outline.show")}
aria-label={t("chat.outline.show")}
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
className="absolute right-3 top-2 flex h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
>
<FileText className="h-3.5 w-3.5" />
{activity.outputs.length}
@@ -217,7 +552,24 @@ export function PaneChat({
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
key={menu.question}
menu={menu}
onChoose={choose}
onAnswer={answer}
onCancel={() => {
if (interruptRun(runId)) setMenu(null);
else toast.error(t("chat.notSent"));
}}
/>
)}
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
>
<PromptEditor
bare
images={images}
@@ -226,7 +578,7 @@ export function PaneChat({
pickFiles={pickFiles}
value={prompt}
onChange={setPrompt}
onSubmit={send}
onSubmit={() => void send()}
placeholder={t("chat.placeholder")}
rows={2}
slash={claude}
@@ -244,16 +596,17 @@ export function PaneChat({
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
<div className="ml-auto flex items-center gap-1">
<ContextMeter tokens={context.tokens} model={context.model} />
{claude && (
<ModelMenu
value={model}
onChange={(v) => {
if (command(`/model ${v || "default"}`)) setModel(v);
onChange={async (v) => {
if (await command(`/model ${v || "default"}`)) setModel(v);
}}
tool={run.tool}
effort={effort}
onEffortChange={(e) => {
if (command(`/effort ${e || "auto"}`)) setEffort(e);
onEffortChange={async (e) => {
if (await command(`/effort ${e || "auto"}`)) setEffort(e);
}}
/>
)}
@@ -263,8 +616,10 @@ export function PaneChat({
data-testid="pane-chat-stop"
onClick={() => {
// An interrupted turn fires no Stop hook: clear it here.
if (interruptRun(runId)) setWorking(false);
else toast.error(t("chat.notStopped"));
if (interruptRun(runId)) {
setWorking(false);
setStops((n) => n + 1);
} else toast.error(t("chat.notStopped"));
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
@@ -276,7 +631,7 @@ export function PaneChat({
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
onClick={() => void send()}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
@@ -291,33 +646,59 @@ export function PaneChat({
</div>
</div>
</div>
{outline &&
(preview ? (
<FilePreview
path={preview}
scope={scope}
onClose={() => setPreview(null)}
// Beside the chat when there is room, over it when not.
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
{preview ? (
<FilePreview
path={preview}
scope={scope}
onClose={() => setPreview(null)}
// Beside the chat when the pane has room, over it when not.
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
)
))}
{list && (
)
)}
{(preview || outlineOpen) && (
// Only beside the chat: a panel over a narrow pane has no edge to drag.
<div className="hidden [@container(min-width:40rem)]:contents">
<Divider
gridRef={rootRef}
dir="row"
at={1 - side}
box={{ x: 0, y: 0, w: 1, h: 1 }}
label={t("chat.outline.resize")}
onRatio={(r) => {
const f = clampSide(1 - r);
localStorage.setItem(SIDE_KEY, String(f));
setDragged(f);
}}
/>
</div>
)}
{list === "skills" && (
<SkillListModal
title={t("chat.outline.skills")}
names={activity.skills}
scope={scope}
onClose={() => setList(null)}
draggable={claude}
/>
)}
{list && list !== "skills" && (
<FileListModal
title={t(`chat.outline.${list}`)}
paths={activity[list]}
scope={scope}
onClose={() => setList(null)}
onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])}
/>
)}
</div>
);
}
});
+139 -19
View File
@@ -10,15 +10,26 @@
* there, on its middle to swap), pick another live session for it, split it
* right or down, or take the pane OUT of this tab (its session keeps running
* in a tab of its own — so its icon is not the tab's stop X), or stop its
* session there (X, `onStop`) without taking it out first. The grid shows
* session there (X, `onStop`, which archives it) or put an AI session to
* sleep (the moon left of it, `onSleep`: stopped but kept to resume) without
* taking it out first. The grid shows
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). While a
* drag is on, the pane it carries is dimmed, the browser's ghost is a small
* chip with its name (`setDragChip`) instead of a whole header, and the drop
* preview names what the drop will do (`dropAction`). The dragged pane's
* header stays mounted even once the tab bar has switched tabs under the
* drag (`ConsoleTabs` spring-loads a hovered tab) — removing the drag's
* source node would lose its `dragend`. A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
* saved and the terminals refit once, not on every pointer move.
* `kept` panes (another workspace's live sessions) are hidden children of
* the SAME list, keyed by `keyOf`, so a pane moves between shown and kept
* without remounting — a workspace switch never re-attaches its terminals.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -31,15 +42,18 @@ import {
GripVertical,
MessageSquare,
Minimize2,
Moon,
Rows2,
SquareTerminal,
X,
} from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { canSleep } from "../../lib/stopSession";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
EMPTY_GROUPS,
type Edge,
type GroupsScope,
type PaneGroups,
type PaneLayout,
type Rect,
@@ -57,16 +71,39 @@ import {
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
let dragSlot: number | null = null;
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
export function startPaneDrag(e: React.DragEvent, slot: number): void {
/** The slot a pane drag carries, or null (no pane drag is on). */
export const paneDragSlot = (): number | null => dragSlot;
/** The drag's ghost: a small chip naming what moves, not a snapshot of a
* whole pane header or tab. Drawn off screen for the browser to copy. */
export function setDragChip(e: React.DragEvent, label: string): void {
const dt = e.dataTransfer;
if (!label || typeof dt?.setDragImage !== "function") return;
const chip = document.createElement("div");
chip.textContent = label;
chip.className =
"fixed -top-96 left-0 max-w-[16rem] truncate rounded-md border border-accent/60 bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg-primary shadow-lg";
document.body.appendChild(chip);
dt.setDragImage(chip, 12, 12);
// The browser copies it during dragstart; it may go right after.
window.setTimeout(() => chip.remove(), 0);
}
/** Starts dragging a pane or a tab (`slot`) onto the grid; `label` names it
* on the drag's chip. */
export function startPaneDrag(e: React.DragEvent, slot: number, label?: string): void {
dragSlot = slot;
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
if (label) setDragChip(e, label);
}
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
@@ -82,10 +119,10 @@ export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
/** A workspace's tabs (groups of split panes), loaded and saved per
* workspace. An update that lands after a workspace switch never writes into
* the new one. */
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
const load = (id: number | null) => ({
* workspace (or once for every workspace, `all`). An update that lands after
* a workspace switch never writes into the new one. */
export function usePaneGroups(laneId: GroupsScope | null): [PaneGroups, GroupsUpdate] {
const load = (id: GroupsScope | null) => ({
laneId: id,
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
});
@@ -133,6 +170,14 @@ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** What a drop does, as an i18n key (`lanes` namespace): the center swaps a
* pane already shown, or shows one from another tab in the target's place
* (the target gets a tab of its own); an edge places it on that side. */
export function dropAction(l: PaneLayout, src: number, zone: Zone): string {
if (zone !== "center") return `panes.drop.${zone}`;
return leaves(l.root).includes(src) ? "panes.drop.swap" : "panes.drop.replace";
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
* an outside click or Escape. */
function PaneMenu({
@@ -194,7 +239,18 @@ const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
* unsent prompt goes along: the terminal's input box into the chat's draft,
* the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
@@ -208,7 +264,14 @@ function ChatToggle({ run }: { run?: RunHandle }) {
aria-pressed={chat}
title={label}
aria-label={label}
onClick={() => setSessionChat(sessionId, !chat)}
onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
const draft = readChatDraft(sessionId);
const text = takeRunInput(run.id);
if (text)
writeChatDraft(sessionId, [draft.prompt, text].filter(Boolean).join("\n"), draft.images);
setSessionChat(sessionId, true);
}}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
{chat ? (
@@ -343,10 +406,19 @@ export function PaneGrid({
onNewSplit,
stateOf,
onStop,
onSleep,
keyOf = (slot) => slot,
kept = [],
}: {
layout: PaneLayout;
/** Stops the session in `slot` (the header's X). */
/** A pane's React key: unique across `mounted` and `kept` (a `paneKey`). */
keyOf?: (slot: number) => number;
/** Panes kept mounted, always hidden — never in the layout. */
kept?: { key: number; node: ReactNode }[];
/** Stops (and archives) the session in `slot` (the header's X). */
onStop?: (slot: number) => void;
/** Sleeps the AI session in `slot`: stopped, kept to resume (the moon). */
onSleep?: (slot: number) => void;
/** A live AI run's state for its header dot (none for a shell). */
stateOf?: (r: RunHandle) => RunState | null;
/** Every slot to keep mounted; those not in the layout are hidden. */
@@ -367,6 +439,8 @@ export function PaneGrid({
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
// The slot a pane drag carries (a header here, or a tab from the bar).
const [dragging, setDragging] = useState<number | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -385,6 +459,7 @@ export function PaneGrid({
const end = () => {
dragSlot = null;
setDrop(null);
setDragging(null);
};
window.addEventListener("dragend", end);
window.addEventListener("drop", end);
@@ -448,6 +523,7 @@ export function PaneGrid({
if (!isPaneDrag(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragSlot !== dragging) setDragging(dragSlot);
const z = zoneAt(e);
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
@@ -464,32 +540,52 @@ export function PaneGrid({
const z = zoneAt(e);
dragSlot = null;
setDrop(null);
setDragging(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
>
{mounted.map((slot) => {
{[
...mounted.map((slot) => ({ key: keyOf(slot), slot, node: null as ReactNode })),
...kept.map((k) => ({ key: k.key, slot: null, node: k.node })),
].map(({ key, slot, node }) => {
if (slot === null) {
// The same element shape as a hidden pane below, so React keeps it.
return (
<div key={key} data-testid={`pane-kept-${key}`} className="hidden">
{false}
<div className="flex min-h-0 flex-1 flex-col">{node}</div>
</div>
);
}
const r = rectOf.get(slot);
const run = runOf(slot);
const focused = layout.focus === slot;
const moving = dragging === slot;
return (
<div
key={slot}
key={key}
data-testid={`pane-${slot}`}
data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
data-menu-bounds
className={r ? "absolute flex flex-col" : "hidden"}
data-dragging={moving ? "true" : undefined}
className={r ? `absolute flex flex-col${moving ? " opacity-40" : ""}` : "hidden"}
style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)}
// Tabbing into a pane focuses it as a click does.
onFocusCapture={() => r && focus(slot)}
>
{multi && r && (
{/* The dragged pane's header stays while the drag lasts, even
hidden in another tab: it is the drag's source node. */}
{((multi && r) || moving) && (
<div
data-testid={`pane-header-${slot}`}
draggable
onDragStart={(e) => startPaneDrag(e, slot)}
onDragStart={(e) => {
startPaneDrag(e, slot, run ? label(run) : t("panes.empty"));
setDragging(slot);
}}
onContextMenu={(e) => paneCtx.open(e, slot)}
title={t("panes.drag")}
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
@@ -552,6 +648,18 @@ export function PaneGrid({
>
<Minimize2 className="h-3.5 w-3.5" />
</button>
{run && onSleep && canSleep(run) && (
<button
type="button"
data-testid={`pane-sleep-${slot}`}
title={t("panes.sleep")}
aria-label={t("panes.sleep")}
onClick={() => onSleep(slot)}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<Moon className="h-3.5 w-3.5" />
</button>
)}
{run && onStop && (
<button
type="button"
@@ -607,6 +715,11 @@ export function PaneGrid({
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
{t("panes.closeShort")}
</MenuItem>
{onSleep && canSleep(runOf(paneCtx.menu.item)) && (
<MenuItem testId="pane-menu-sleep" onSelect={pickPane(onSleep)}>
{t("panes.sleep")}
</MenuItem>
)}
{onStop && runOf(paneCtx.menu.item) && (
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
{t("panes.stop")}
@@ -624,12 +737,19 @@ export function PaneGrid({
}
/>
))}
{preview && (
{preview && drop && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
className="pointer-events-none absolute z-20 flex items-center justify-center rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(preview)}
/>
>
<span
data-testid="pane-drop-label"
className="max-w-[90%] truncate rounded-md bg-accent-solid px-2.5 py-1 text-xs font-medium text-white shadow-lg"
>
{t(dropAction(layout, drop.src, drop.zone))}
</span>
</div>
)}
</div>
);
+134 -50
View File
@@ -9,8 +9,11 @@
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a Files panel row
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh;
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
@@ -60,10 +63,14 @@ import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
droppedAttachment,
droppedAttachments,
droppedSkill,
droppedText,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -221,6 +228,15 @@ export function PromptEditor({
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
// With a `/command` or `@path` in it, the textarea's own text turns
// transparent and a mirror over it — same box, font and wrapping, scrolled
// with it — draws the text with those tokens coloured.
const mirrorRef = useRef<HTMLDivElement | null>(null);
const tokens = useMemo(() => promptTokens(value), [value]);
const marked = tokens.some((tok) => tok.kind !== "text");
const box = bare
? "block max-h-[40vh] w-full overflow-y-auto px-1 py-1 text-sm"
: "w-full rounded-md border px-3 py-2 text-[11px] font-mono";
// A composer grows with its text: reset to its rows, then take what the
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
useLayoutEffect(() => {
@@ -229,6 +245,10 @@ export function PromptEditor({
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [bare, value]);
// A mirror that just appeared starts at the top, whatever the textarea shows.
useLayoutEffect(() => {
if (mirrorRef.current && taRef.current) mirrorRef.current.scrollTop = taRef.current.scrollTop;
}, [marked]);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
@@ -388,6 +408,58 @@ export function PromptEditor({
const fileInput = useRef<HTMLInputElement | null>(null);
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
// A drop lands anywhere on the screen around the composer — the nearest
// `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home),
// else a `bare` editor's parent, the box every caller draws around it and
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
// `/command`, a file is uploaded, a local `file://` list an `@path` chip
// and any other text is inserted.
const rootRef = useRef<HTMLDivElement | null>(null);
const dropRef = useRef<(e: DragEvent) => void>(() => {});
dropRef.current = (e) => {
const atts = droppedAttachments(e.dataTransfer);
const skill = droppedSkill(e.dataTransfer);
if (atts.length) {
e.preventDefault();
onImagesChange((prev) => addAttachments(prev, atts));
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
// On the textarea itself the browser inserts text at the drop point.
const dropped = droppedText(e.dataTransfer);
if (!dropped || ("text" in dropped && e.target === taRef.current)) return;
e.preventDefault();
if ("attachments" in dropped)
onImagesChange((prev) => addAttachments(prev, dropped.attachments));
else insert(dropped.text);
}
};
useEffect(() => {
const zone =
rootRef.current?.closest<HTMLElement>("[data-prompt-drop]") ??
(bare ? rootRef.current?.parentElement : rootRef.current);
if (!zone) return;
const over = (e: DragEvent) => {
const types = e.dataTransfer?.types ?? [];
const accepts = [ATTACH_MIME, SKILL_MIME, "Files", "text/uri-list", "text/plain"];
if (!accepts.some((m) => types.includes(m))) return;
e.preventDefault();
e.dataTransfer!.dropEffect = "copy";
};
const drop = (e: DragEvent) => dropRef.current(e);
zone.addEventListener("dragover", over);
zone.addEventListener("drop", drop);
return () => {
zone.removeEventListener("dragover", over);
zone.removeEventListener("drop", drop);
};
}, [bare]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
@@ -431,7 +503,7 @@ export function PromptEditor({
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
return (
<div className="relative">
<div ref={rootRef} className="relative">
{images.length > 0 && (
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
{images.map((img) => (
@@ -450,7 +522,7 @@ export function PromptEditor({
) : (
<span
title={img.path}
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
className="flex h-14 w-40 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{img.name}</span>
@@ -503,52 +575,64 @@ export function PromptEditor({
}}
/>
)}
<textarea
ref={taRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
<div className="relative">
<textarea
ref={taRef}
onScroll={(e) => {
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
}}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}
}
}}
onDragOver={(e) => {
// A Files panel row is not a file: say a drop is welcome.
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
}}
onDrop={(e) => {
const att = droppedAttachment(e.dataTransfer);
if (att) {
e.preventDefault();
onImagesChange((prev) => addAttachments(prev, [att]));
return;
}
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={
bare
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
}
/>
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
/>
{marked && (
<div
ref={mirrorRef}
aria-hidden
data-testid="prompt-mirror"
// Its own scrollbar keeps the width the textarea's takes, but is
// invisible, so the real one shows through.
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
>
{tokens.map((tok, i) =>
tok.kind === "text" ? (
tok.text
) : (
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
{tok.text}
</span>
)
)}
{/* A trailing newline is a line in a textarea, not in a div. */}
{"\u200b"}
</div>
)}
</div>
{upload && (
<p
role={upload === "failed" ? "alert" : "status"}
+179 -23
View File
@@ -14,10 +14,10 @@
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped on it are saved on the server
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -25,11 +25,15 @@
* from its terminal — announced by a hint the textarea is described by;
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
* An input method that edits the textarea without composition events (macOS
* Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone
* dropped its capitals and the words it rewrites.
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
* (OSC 52 — tmux's default `set-clipboard external` passes its own
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
* READ the user's clipboard). While the socket is down a "Reconnecting…"
* READ the user's clipboard) — over plain HTTP too, through `writeClipboard`'s
* textarea fallback. While the socket is down a "Reconnecting…"
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
* most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
@@ -37,7 +41,16 @@
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
import {
memo,
useEffect,
useId,
useMemo,
useRef,
useState,
type ClipboardEvent,
type DragEvent,
} from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
@@ -48,10 +61,13 @@ import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "uploading" | "failed";
type DropState = "hint" | "paste" | "uploading" | "failed";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -73,9 +89,11 @@ const clipboardProvider: IClipboardProvider = {
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
// clipboard. An empty write (a "clear") is ignored.
writeText: (_selection, text) => {
// Silent and best effort: no toast per copy, and on a non-secure origin
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
// Silent and best effort: no toast per copy. On a non-secure origin
// (`http://<lan-ip>`, no Clipboard API) writeClipboard falls back to the
// textarea copy, which the browser allows while the drag that made the
// selection still counts as the user's gesture.
if (text) void writeClipboard(text);
},
};
@@ -122,6 +140,10 @@ export function loadXterm(): Promise<Xterm> {
const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
/** A text drag (a Files/Changes row, a skill, selected text): xterm.js has no
* drop handling of its own, so the pane pastes it, as a local terminal does. */
const hasText = (e: DragEvent) =>
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
interface TerminalViewProps {
runId: string;
@@ -133,9 +155,22 @@ interface TerminalViewProps {
/** Take the keyboard when it appears (default). Off for a split pane that
* is not the focused one, or keys would land in whichever mounted last. */
autoFocus?: boolean;
/** Another layer (the pane's chat) covers it. xterm stops drawing only when
* its IntersectionObserver says off screen, and a covered terminal is not —
* so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean;
}
export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: TerminalViewProps) {
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
* a terminal only cares about its own props (its owner keeps `onExit` stable). */
export const TerminalView = memo(function TerminalView({
runId,
wsBaseUrl,
onExit,
autoFocus = true,
covered = false,
}: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
// every render, and as an effect dep it would tear the terminal down and
@@ -184,11 +219,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
[]
);
const onDrop = async (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const onDrop = (e: DragEvent) => {
if (hasText(e)) {
e.preventDefault();
dragDepth.current = 0;
setDrop(null);
const text = e.dataTransfer.getData("text/plain");
if (text) termRef.current?.paste(text + " ");
termRef.current?.focus();
return;
}
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
void attachFiles(Array.from(e.dataTransfer.files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
// text/plain and would paste nothing. Capture phase, because xterm's own
// paste listener stops the event. Text is xterm's to paste, Ctrl+V's too;
// only a Ctrl+V with nothing on the clipboard is the pane's ^V again
// (vim's block select, readline's quoted insert).
const onPaste = (e: ClipboardEvent) => {
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
if (files.length === 0) return;
setDrop("uploading");
try {
@@ -233,7 +299,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled.
const term = new Terminal({
fontSize: 13,
fontSize: readTerminalFontSize(),
cursorBlink: true,
fontFamily: TERMINAL_FONTS[readTerminalFont()],
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
@@ -289,9 +355,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
// because that protocol reports button presses only, never the wheel — so
// xterm takes the emulation path for it as well.
term.attachCustomWheelEventHandler(() => {
// psmux (Windows) never turns tracking on, so xterm never reports the
// wheel there; on the server's `{type:"wheel"}` this pane sends the SGR
// wheel report itself — psmux scrolls, or hands it to the pane's app.
// ponytail: at 1;1, which is all psmux reports to the app anyway.
let ownWheel = false;
term.attachCustomWheelEventHandler((ev) => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
if (!tracked && ownWheel) {
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
}
return false;
}
return tracked || term.buffer.active.type !== "alternate";
});
@@ -327,6 +404,29 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
// Ctrl+V: xterm would send ^V, and claude would then read the
// SERVER's clipboard, never this browser's. Let the browser paste
// instead (onPaste: a file's path, or the text); a browser that fires
// no paste, or one with nothing on the clipboard, still sends ^V.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
ctrlVRef.current = true;
setTimeout(() => {
if (!ctrlVRef.current) return;
ctrlVRef.current = false;
term.input("\x16", true);
}, 0);
return false;
}
// Ctrl+Z: a terminal sends ^Z (SIGTSTP), which SUSPENDS claude or
// codex — the pane drops to its shell and the session looks closed,
// with nothing on screen saying `fg` brings it back. In a browser
// Ctrl+Z means undo, so it sends Ctrl+_ — undo in Claude Code's prompt
// and in bash/readline — and never suspends the program.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "z") {
ev.preventDefault();
term.input("\x1f", true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -441,6 +541,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") {
takeLead();
} else if (msg.type === "wheel") {
ownWheel = true;
} else if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
@@ -486,6 +588,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert);
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
// textarea with no composition events: a capital arrives while Shift is
// held and every later key REPLACES the word — both dropped by xterm
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
const detachIme = attachImeInput(term);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
@@ -506,6 +614,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
// Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => {
const size = readTerminalFontSize();
const family = TERMINAL_FONTS[readTerminalFont()];
if (term.options.fontSize === size && term.options.fontFamily?.trimEnd() === family) return;
term.options.fontSize = size;
term.options.fontFamily = family;
refit();
};
window.addEventListener(FONT_SIZE_EVENT, onFontPref);
return () => {
if (retryTimer) clearTimeout(retryTimer);
@@ -513,7 +632,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert);
detachIme();
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
@@ -562,6 +683,35 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
(data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
},
// The rows on screen, dim cells (a TUI's placeholder) left out.
// `menuOnly` (the chat's 700 ms menu poll): plain text first, and the
// cell-by-cell read only when a row could be a menu's cursor row.
(menuOnly = false) => {
const term = termRef.current;
if (!term) return null;
const buf = term.buffer.active;
if (menuOnly) {
let maybe = false;
for (let y = 0; y < term.rows && !maybe; y++)
maybe = MENU_CURSOR_ROW.test(
buf.getLine(buf.baseY + y)?.translateToString(true) ?? ""
);
if (!maybe) return null;
}
const rows: string[] = [];
const reuse = buf.getNullCell();
for (let y = 0; y < term.rows; y++) {
const line = buf.getLine(buf.baseY + y);
let row = "";
for (let x = 0; line && x < line.length; x++) {
const cell = line.getCell(x, reuse);
// Width 0: the right half of a wide character.
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
}
rows.push(row);
}
return { rows, cols: term.cols };
}
),
[runId]
@@ -601,25 +751,31 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
style={{ backgroundColor: palette.background }}
onDragEnter={(e) => {
if (!hasFiles(e)) return;
if (!hasFiles(e) && !hasText(e)) return;
e.preventDefault();
dragDepth.current += 1;
setDrop("hint");
setDrop(hasFiles(e) ? "hint" : "paste");
}}
onDragOver={(e) => {
if (!hasFiles(e)) return;
if (!hasFiles(e) && !hasText(e)) return;
e.preventDefault(); // without it the browser refuses the drop
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!hasFiles(e)) return;
if (!hasFiles(e) && !hasText(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
if (dragDepth.current === 0) setDrop((d) => (d === "hint" || d === "paste" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
onDrop={onDrop}
onPasteCapture={onPaste}
onFocus={() => notePaneFocus(runId)}
>
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
<div
ref={containerRef}
className="h-full w-full"
style={covered ? { transform: "translateX(-300vw)" } : undefined}
data-testid="terminal-view"
/>
<p id={hintId} className="sr-only">
{t("terminal.keyboardHint")}
</p>
@@ -720,4 +876,4 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
)}
</div>
);
}
});
@@ -2,14 +2,17 @@
* @file ChatFilePreview.test.tsx
* @description Where a Chat pane's file preview reads from: a file under the
* pane's folder through the lane's git file read, a memory file under
* `~/.claude` through the config pages' read, anything else by path only; and
* the list dialog previewing the file picked in it.
* `~/.claude` through the config pages' read, a dropped text file through the
* drop route, an image read outside the folder through the session's image
* route, a text file read or written outside it through the session's file route, a Windows path like a POSIX one, anything else by path only; the
* list dialog previewing the file picked in it; and the skills dialog finding
* each skill's SKILL.md by name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
import { FileListModal, FilePreview, SkillListModal, displayName } from "../ChatFilePreview";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -28,7 +31,37 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}),
gitRawUrl: () => "",
},
sessions: {
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
readFile: vi.fn((_id: string, p: string) =>
p.endsWith(".md")
? Promise.resolve({ text: "# Audit report", truncated: false })
: Promise.reject(new Error("404"))
),
},
ccConfig: {
skills: vi.fn().mockResolvedValue({
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
}),
plugins: vi.fn().mockResolvedValue({
plugins: [
{
name: "agenthub",
contributes: {
items: {
skills: [
{
name: "file-headers",
description: null,
file: "/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md",
},
],
commands: [],
},
},
},
],
}),
file: vi
.fn()
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
@@ -53,10 +86,48 @@ describe("FilePreview", () => {
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
});
it("shows only the path of a file it may not read", () => {
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
it("reads Windows paths the same way", async () => {
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
cleanup();
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
render(<FilePreview path={tool} scope={win} onClose={() => {}} />);
expect(await screen.findByText("Launch notes")).toBeTruthy();
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(tool);
expect(screen.getByText("b")).toBeTruthy(); // the name, not the whole path
});
it("reads a dropped text file through the drop route", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("a long paste"));
vi.stubGlobal("fetch", fetchMock);
const drop = "C:\\Users\\k\\.claude\\agenthub\\drops\\0123456789abcdef-pasted-text.txt";
render(<FilePreview path={drop} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("a long paste")).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith("/api/run/drop/0123456789abcdef-pasted-text.txt");
vi.unstubAllGlobals();
});
it("shows an image it read outside the folder through the session, its path if refused", () => {
render(<FilePreview path="/h/.cache/shot.png" scope={scope} onClose={() => {}} />);
const img = screen.getByAltText("shot.png") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("/img/s1?path=/h/.cache/shot.png");
fireEvent.error(img);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
it("reads a text file it wrote outside the folder through the session", async () => {
const report = "/home/a/Documents/audit/REPORT_ssh_ec2.md";
render(<FilePreview path={report} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("Audit report")).toBeTruthy();
expect(vi.mocked(api.sessions.readFile)).toHaveBeenCalledWith("s1", report);
});
it("shows only the path of a file it may not read", async () => {
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
expect(await screen.findByText("No preview for this file here")).toBeTruthy();
});
});
describe("FileListModal", () => {
@@ -76,3 +147,28 @@ describe("FileListModal", () => {
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
});
});
describe("SkillListModal", () => {
it("lists the skills by name and previews each one's SKILL.md", async () => {
render(
<SkillListModal
title="Skills"
names={["agenthub:file-headers", "xlsx", "simplify"]}
scope={scope}
onClose={() => {}}
/>
);
// A plugin's skill first, read through the config read.
expect(await screen.findByRole("button", { name: "agenthub:file-headers" })).toBeTruthy();
await screen.findByText("Launch notes");
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(
"/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md"
);
fireEvent.click(screen.getByRole("button", { name: "xlsx" }));
await screen.findByText("Launch notes");
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith("/h/.claude/skills/xlsx/SKILL.md");
// A built-in skill has no file.
fireEvent.click(screen.getByRole("button", { name: "simplify" }));
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
});
@@ -3,7 +3,7 @@
* @description The Chat pane's activity panel renders a session's outline the
* way Claude Desktop does: progress with done/current items, outputs by name
* with an EXT tag (a click opens that file), and "Used in this session" rows —
* uploads, files read and memory open their list, skills link to the config.
* uploads, files read, memory and skills open their list.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
);
expect(screen.getByText("Parse plan").className).toContain("line-through");
expect(screen.getByText("Build report").className).not.toContain("line-through");
// How far the plan is, and which step is being worked on.
expect(screen.getByText("1/2")).toBeTruthy();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step");
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
@@ -52,15 +56,47 @@ describe("ChatOutline", () => {
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
fireEvent.click(screen.getByTitle("Memory: release"));
expect(onOpenList).toHaveBeenCalledWith("memory");
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
"/settings/agents/claude-code?tab=skills"
);
fireEvent.click(screen.getByTitle("Skills: xlsx"));
expect(onOpenList).toHaveBeenCalledWith("skills");
expect(screen.getByText("vercel_vercel")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
expect(onClose).toHaveBeenCalled();
});
it("drags its rows into the prompt box", () => {
render(
<MemoryRouter>
<ChatOutline
outline={outline}
onClose={() => {}}
onOpenFile={() => {}}
onOpenList={() => {}}
/>
</MemoryRouter>
);
const dragged = (el: HTMLElement) => {
const data: Record<string, string> = {};
fireEvent.dragStart(el, {
dataTransfer: { setData: (k: string, v: string) => (data[k] = v) },
});
return data;
};
const attached = (el: HTMLElement) =>
JSON.parse(dragged(el)["application/x-agenthub-attach"] ?? "[]") as { path: string }[];
expect(attached(screen.getByRole("button", { name: "XLSX: Progress report" }))).toEqual([
{ path: "@/w/out/Progress report.xlsx", name: "Progress report.xlsx" },
]);
expect(attached(screen.getByTitle(/a\.ts, /)).map((a) => a.path)).toEqual([
"@/w/src/a.ts",
"@/w/src/b.ts",
"@/w/src/c.ts",
]);
// An upload goes as the composer attaches one: its bare path.
expect(attached(screen.getByTitle("Uploads: minutes.xlsx"))[0]!.path).toBe(outline.uploads[0]);
expect(dragged(screen.getByTitle("Skills: xlsx"))["application/x-agenthub-skill"]).toBe("xlsx");
});
it("hides progress and the used section when there is none", () => {
render(
<MemoryRouter>
@@ -0,0 +1,126 @@
/**
* @file ConsoleTabs.test.tsx
* @description The tab bar's spring-loaded tabs: a pane drag resting on
* another tab shows it after `SPRING_MS`, so the pane can be dropped on that
* tab's panes; moving off before then, or resting on the tab already holding
* the pane, shows nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, split, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs, SPRING_MS } from "../ConsoleTabs";
import { PANE_DRAG_TYPE, paneDragSlot } from "../PaneGrid";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const run = (slot: number): RunHandle => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
tool: "shell",
});
// Tab 0: pane 0 alone (on screen); tab 1: panes 1 and 2.
const GROUPS: PaneGroups = {
groups: [
{ root: pane(0), focus: 0 },
{ root: split("row", pane(1), pane(2)), focus: 1 },
],
active: 0,
};
function setup() {
let groups = GROUPS;
const onGroupsChange = vi.fn((fn: (g: PaneGroups) => PaneGroups) => {
groups = fn(groups);
});
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[run(0), run(1), run(2)]}
groups={GROUPS}
onGroupsChange={onGroupsChange}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
// jsdom lays nothing out: tab i spans x 100i … 100i+100.
for (const el of document.querySelectorAll<HTMLElement>("[data-tab-index]")) {
const i = Number(el.dataset.tabIndex);
el.getBoundingClientRect = () =>
({
left: i * 100,
right: i * 100 + 100,
width: 100,
top: 0,
bottom: 40,
height: 40,
}) as DOMRect;
}
return { onGroupsChange, groups: () => groups };
}
const dataTransfer = () => ({
types: [PANE_DRAG_TYPE],
setData: vi.fn(),
getData: () => "",
effectAllowed: "",
dropEffect: "",
});
function drag(type: "dragStart" | "dragOver", el: Element, clientX = 0) {
const ev = createEvent[type](el, { dataTransfer: dataTransfer() });
Object.defineProperty(ev, "clientX", { value: clientX });
fireEvent(el, ev);
}
const tabEl = (key: string) => screen.getByTestId(`console-tab-${key}`).parentElement as Element;
describe("ConsoleTabs spring-loaded tabs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("a pane drag resting on another tab shows that tab", () => {
const { onGroupsChange, groups } = setup();
drag("dragStart", tabEl("0"));
expect(paneDragSlot()).toBe(0);
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS - 1));
expect(onGroupsChange).not.toHaveBeenCalled();
act(() => void vi.advanceTimersByTime(1));
expect(groups().active).toBe(1);
});
it("moving off the tab first, or resting on the pane's own tab, shows nothing", () => {
const { onGroupsChange } = setup();
drag("dragStart", tabEl("0"));
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS / 2));
drag("dragOver", tabEl("0"), 50);
act(() => void vi.advanceTimersByTime(SPRING_MS * 2));
expect(onGroupsChange).not.toHaveBeenCalled();
});
});
@@ -7,30 +7,52 @@
* activity panel, collapsed into a file button with the output count, whose
* files open a preview in its place; a file in a turn's "Edited N files" card
* opens in the Workspace's Changes panel when it is in the pane's folder; and
* Stop in Send's place while the main agent works, pressing Esc in the pane.
* Stop in Send's place while the main agent works, pressing Esc in the pane;
* and the menu the CLI asks with, shown as buttons that press its keys, a
* message sent meanwhile typed as its "Type something" answer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat } from "../PaneChat";
import { PaneChat, contextWindow } from "../PaneChat";
import { api } from "../../../lib/api";
import { interruptRun, submitToRun } from "../../../lib/panePaste";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file.
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
ConversationView: ({
onOpenFile,
onNotStarted,
}: {
onOpenFile?: (path: string) => void;
onNotStarted?: (notStarted: boolean) => void;
}) => (
<div data-testid="conversation">
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
{/* The session has no row yet: the CLI has not started it. */}
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
</div>
),
}));
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
readRunMenu: vi.fn(() => null),
chooseRunOption: vi.fn(() => true),
answerRunMenu: vi.fn(() => true),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -65,6 +87,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
skills: [],
mcp: [],
}),
readFile: r({ text: "", truncated: false }),
},
lanes: {
gitFile: r({
@@ -122,7 +145,12 @@ function renderChat(run: Partial<typeof RUN> = {}) {
);
}
beforeEach(() => vi.mocked(submitToRun).mockClear());
beforeEach(() => {
vi.mocked(submitToRun).mockClear();
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
localStorage.removeItem("agenthub.chat.draft.s1");
sessionStorage.clear();
});
describe("PaneChat — the + menu", () => {
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
@@ -196,6 +224,44 @@ describe("PaneChat — Stop", () => {
});
});
describe("PaneChat — waiting on the user", () => {
it("marks a hidden tab while a permission prompt waits", async () => {
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
document.title = "Workspace · AgentHub";
try {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [
{
type: "main",
status: "waiting",
awaiting_input_since: "2026-10-12T08:00:00.000Z",
awaiting_reason: "notification",
},
],
} as never);
const { unmount } = renderChat();
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
unmount();
expect(document.title).toBe("Workspace · AgentHub");
} finally {
vis.mockRestore();
}
});
});
describe("contextWindow", () => {
it("knows Claude's 200K line from the 1M one", () => {
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
expect(contextWindow("gpt-5-codex")).toBeNull();
expect(contextWindow(null)).toBeNull();
});
});
describe("PaneChat — model, effort and permission mode", () => {
it("types /model into the pane", async () => {
renderChat();
@@ -230,10 +296,9 @@ const byId = (id: string) => () => screen.getByTestId(id);
describe("PaneChat — the activity panel", () => {
it("opens from the file button, previews a file, and closes back to the panel", async () => {
localStorage.removeItem("agenthub.chat.outlineOpen");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
</MemoryRouter>
);
expect(screen.queryByTestId("chat-outline")).toBeNull();
@@ -248,7 +313,71 @@ describe("PaneChat — the activity panel", () => {
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
});
it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
const pane = (panelOpen: boolean) => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
</MemoryRouter>
);
const { rerender } = render(pane(true));
await screen.findByTestId("chat-outline");
rerender(pane(false));
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
});
it("starts closed while the session has no output, even in a spanning pane", async () => {
vi.mocked(api.sessions.outline).mockResolvedValueOnce({
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
} as never);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled());
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show").textContent).toBe("0");
});
it("keeps the user's open or close across the chat leaving for the terminal", async () => {
const pane = () => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const first = render(pane());
fireEvent.click(
within(await screen.findByTestId("chat-outline")).getByRole("button", {
name: "Hide side panel",
})
);
first.unmount();
render(pane());
await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2"));
expect(screen.queryByTestId("chat-outline")).toBeNull();
});
it("resizes from its splitter and remembers the width", async () => {
localStorage.removeItem("agenthub.chat.panelWidth");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
expect(splitter.getAttribute("aria-valuenow")).toBe("65");
});
});
@@ -259,7 +388,7 @@ describe("PaneChat — an edited file", () => {
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("edited-in"));
@@ -272,3 +401,153 @@ describe("PaneChat — an edited file", () => {
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
});
});
describe("PaneChat — the unsent prompt", () => {
it("keeps the half-typed prompt and its attachments when the chat unmounts (terminal view, reload)", async () => {
const first = renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
target: { value: "half typed" },
});
// A dropped attachment, as a Files row or an "Edited N files" row brings it.
const drop = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(drop, "dataTransfer", {
value: {
types: ["application/x-agenthub-attach"],
getData: (t: string) =>
t === "application/x-agenthub-attach"
? JSON.stringify({ path: "@src/a.ts", name: "a.ts" })
: "",
files: [],
},
});
act(() => {
screen.getByPlaceholderText("Write a message…").dispatchEvent(drop);
});
expect(await screen.findByText("a.ts")).toBeInTheDocument();
first.unmount();
renderChat();
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
// A drag from outside the dashboard (a file manager, VS Code, a selection)
// carries only text: the terminal beneath pastes it, so the chat takes it too.
const textDrop = (data: Record<string, string>) => {
const drop = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(drop, "dataTransfer", {
value: { types: Object.keys(data), getData: (t: string) => data[t] ?? "", files: [] },
});
act(() => {
screen.getByTestId("pane-chat").dispatchEvent(drop);
});
return drop;
};
it("attaches the local files of a dropped file:// list", async () => {
renderChat();
const drop = textDrop({
"text/uri-list": "file:///home/u/My%20Docs/spec.md\r\nfile:///home/u/b.ts",
"text/plain": "/home/u/My Docs/spec.md",
});
expect(drop.defaultPrevented).toBe(true);
expect(await screen.findByText("spec.md")).toBeInTheDocument();
expect(screen.getByText("b.ts")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
target: { value: "read" },
});
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
);
});
it("inserts dropped plain text into the prompt", () => {
renderChat();
const drop = textDrop({ "text/plain": "src/lib/x.ts" });
expect(drop.defaultPrevented).toBe(true);
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("src/lib/x.ts");
});
// Issue #73: before the session starts the CLI may be asking in the terminal
// (trust this folder? — "No, exit" preselected); an Enter sent from here
// would answer it, so a send asks first.
it("asks before sending while the session has not started", async () => {
vi.mocked(submitToRun).mockClear();
confirmDialog.mockClear();
renderChat();
fireEvent.click(screen.getByTestId("not-started"));
const box = screen.getByPlaceholderText("Write a message…");
fireEvent.change(box, { target: { value: "hello" } });
confirmDialog.mockResolvedValueOnce(false);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
expect(submitToRun).not.toHaveBeenCalled();
expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
});
it("sends without asking once the session has started", async () => {
vi.mocked(submitToRun).mockClear();
confirmDialog.mockClear();
renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
expect(confirmDialog).not.toHaveBeenCalled();
});
});
describe("PaneChat — a menu the CLI asks with", () => {
const MENU: PaneMenu = {
question: "Which color?",
options: [
{ n: 1, label: "Red", hint: "warm", checked: null },
{ n: 2, label: "Type something.", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: 1,
};
it("shows the question as buttons in the box's place, and a pick presses its key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
try {
renderChat();
const card = await screen.findByTestId("pane-chat-menu");
expect(card).toHaveTextContent("Which color?");
expect(card).toHaveTextContent("warm");
// The prompt box is hidden (kept mounted) while the CLI asks.
expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull();
fireEvent.click(screen.getByText("Red"));
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0);
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("types the answer in the card's own field, never pressing that row's key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
vi.mocked(chooseRunOption).mockClear();
vi.mocked(submitToRun).mockClear();
try {
renderChat();
fireEvent.click(await screen.findByText("Type something."));
expect(chooseRunOption).not.toHaveBeenCalled();
const field = screen.getByTestId("pane-chat-menu-input");
expect(field).toHaveFocus();
fireEvent.change(field, { target: { value: "purple" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple");
expect(submitToRun).not.toHaveBeenCalled();
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
});
@@ -2,12 +2,13 @@
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* current box, and nothing for a drop that changes nothing; and the label
* naming what a drop does (`dropAction`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { dropAction, dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
@@ -34,3 +35,15 @@ describe("dropPreview", () => {
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
describe("dropAction", () => {
it("an edge places the pane on that side", () => {
expect(dropAction(AB, 0, "left")).toBe("panes.drop.left");
expect(dropAction(AB, 2, "bottom")).toBe("panes.drop.bottom");
});
it("the center swaps a shown pane, or shows one from another tab in its place", () => {
expect(dropAction(AB, 0, "center")).toBe("panes.drop.swap");
expect(dropAction(AB, 2, "center")).toBe("panes.drop.replace");
});
});
@@ -5,7 +5,7 @@
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path (and
* remembered as a recent upload), a Files panel row dropped as an `@path`
* chip, a composer
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
@@ -16,7 +16,7 @@
*/
import { useState } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import {
@@ -28,7 +28,13 @@ import {
type PromptImage,
} from "../RunSetup";
import { api } from "../../../lib/api";
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
import {
ATTACH_MIME,
dragAttachments,
dragProjectPath,
dragSkill,
recentUploads,
} from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -111,6 +117,31 @@ function renderEditor(value: string, slash = true) {
const box = () => screen.getByPlaceholderText("Ask anything…");
describe("PromptEditor — / and @ suggestions", () => {
it("colours /command and @path tokens through a mirror, only when there are any", () => {
const { unmount } = render(
<PromptEditor
value={"/review @src/a.ts please"}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
onChange={() => {}}
onSubmit={() => {}}
/>
);
const mirror = screen.getByTestId("prompt-mirror");
expect(mirror).toHaveAttribute("aria-hidden");
expect(within(mirror).getByText("/review").className).toContain("text-accent");
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
expect(box().className).toContain("text-transparent");
unmount();
renderEditor("plain words");
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
expect(box().className).toContain("text-fg-primary");
});
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const spies = renderEditor("");
fireEvent.change(box(), { target: { value: "/rev" } });
@@ -348,6 +379,134 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
});
it("takes a drop anywhere on a bare editor's box; an extra folder's row comes absolute", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<div>
<PromptEditor
bare
slash={false}
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<div data-testid="toolbar" />
</div>
<section data-drag-root="/Users/tester/extra/">
<span data-testid="row" />
</section>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const data: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (data[k] = v),
getData: (k: string) => data[k] ?? "",
get types() {
return Object.keys(data);
},
files: [],
};
dragProjectPath(
{ dataTransfer, currentTarget: screen.getByTestId("row") } as unknown as React.DragEvent,
"notes.md"
);
expect(data["text/plain"]).toBe("/Users/tester/extra/notes.md");
const over = createEvent.dragOver(screen.getByTestId("toolbar"), { dataTransfer });
fireEvent(screen.getByTestId("toolbar"), over);
expect(over.defaultPrevented).toBe(true);
fireEvent.drop(screen.getByTestId("toolbar"), { dataTransfer });
expect(screen.getByTestId("sent").textContent).toBe("read @/Users/tester/extra/notes.md");
});
it("takes a drop anywhere inside its data-prompt-drop screen, not only on the box", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<div data-prompt-drop>
<div data-testid="transcript" />
<PromptEditor
value="see"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</div>
);
}
render(<Harness />);
const dataTransfer = {
getData: (k: string) =>
k === ATTACH_MIME ? JSON.stringify([{ path: "@/w/a.ts", name: "a.ts" }]) : "",
types: [ATTACH_MIME],
files: [],
};
const over = createEvent.dragOver(screen.getByTestId("transcript"), { dataTransfer });
fireEvent(screen.getByTestId("transcript"), over);
expect(over.defaultPrevented).toBe(true);
fireEvent.drop(screen.getByTestId("transcript"), { dataTransfer });
expect(screen.getByTestId("sent").textContent).toBe("see @/w/a.ts");
});
it("takes an activity panel's drag: a group of files as chips, a skill as the /command", () => {
function Harness() {
const [value, setValue] = useState("check it");
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
slash={false}
value={value}
onChange={setValue}
onSubmit={() => {}}
placeholder="Ask anything…"
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages(value, images)}</output>
</>
);
}
render(<Harness />);
const drag = (start: (e: React.DragEvent) => void) => {
const data: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (data[k] = v),
getData: (k: string) => data[k] ?? "",
get types() {
return Object.keys(data);
},
files: [],
};
start({ dataTransfer } as unknown as React.DragEvent);
fireEvent.drop(box(), { dataTransfer });
};
drag((e) =>
dragAttachments(e, [
{ path: "@/a/x.md", name: "x.md" },
{ path: "/d/drops/0123456789abcdef.png", name: "y.png" },
])
);
drag((e) => dragSkill(e, "ponytail:ponytail-help"));
expect(screen.getByTestId("sent").textContent).toBe(
"/ponytail:ponytail-help check it @/a/x.md /d/drops/0123456789abcdef.png"
);
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), {
@@ -2,7 +2,8 @@
* @file TerminalView.test.tsx
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* mocked terminal, forwards typed input (Safari Telex word replacements
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching.
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
@@ -69,6 +73,10 @@ vi.mock("@xterm/xterm", () => ({
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
input: inputMock,
textarea: (textarea = document.createElement("textarea")),
// xterm's own element, holding its textarea (lib/imeInput listens on it).
element: document.createElement("div").appendChild(textarea).parentElement,
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})),
}));
@@ -223,6 +231,24 @@ describe("TerminalView", () => {
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
// psmux (Windows) never turns mouse tracking on for its client, so xterm
// never reports the wheel and the pane could not be scrolled at all.
termState.modes.mouseTrackingMode = "none";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
ws.onmessage?.({ data: '{"type":"wheel"}' });
const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
// Tracked again (a tmux that does report): xterm sends it, never twice.
termState.modes.mouseTrackingMode = "any";
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
expect(ws.sent).toHaveLength(2);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
@@ -503,4 +529,124 @@ describe("TerminalView", () => {
)
);
});
it("pastes the text of a dragged Files-panel row, uploading nothing", () => {
dropFileMock.mockReset();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const data: Record<string, string> = {
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
"text/plain": "src/a.ts",
};
const dataTransfer = {
types: Object.keys(data),
files: [],
getData: (k: string) => data[k] ?? "",
};
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent(
"Drop to paste into the terminal"
);
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
expect(screen.queryByTestId("terminal-drop")).toBeNull();
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a pasted image and pastes its path, like a drop", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-image.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => {
vi.useFakeTimers();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
// Not cancelled: xterm's own paste listener takes the text.
expect(
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
})
).toBe(true);
vi.advanceTimersByTime(1);
expect(inputMock).not.toHaveBeenCalled();
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "" },
});
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
vi.useRealTimers();
});
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
vi.useFakeTimers();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
vi.useRealTimers();
});
// ^Z suspended claude: the pane fell to its shell and the session looked
// closed. Ctrl+Z is undo in a browser, so it sends Ctrl+_ instead.
it("turns Ctrl+Z into undo (Ctrl+_) and never sends ^Z, which would suspend the session", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "z", ctrlKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith("\x1f", true);
expect(inputMock).not.toHaveBeenCalledWith("\x1a", expect.anything());
// Other chords with Z are left to xterm (Ctrl+Shift+Z, plain z).
expect(handler(new KeyboardEvent("keydown", { key: "Z", ctrlKey: true, shiftKey: true }))).toBe(
true
);
expect(handler(new KeyboardEvent("keydown", { key: "z" }))).toBe(true);
});
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
// every later key REPLACES the word — the event order captured on a Mac.
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const type = (value: string) => {
textarea.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertReplacementText" }));
textarea.value = value;
textarea.dispatchEvent(new InputEvent("input", { inputType: "insertReplacementText" }));
};
textarea.value = "x"; // xterm itself sent this one (insertText)
type("xi");
type("xin");
textarea.value = "xin vie";
type("xin viet");
type("xin viêt");
type("xin việt");
expect(inputMock.mock.calls).toEqual([
["i", true],
["n", true],
["t", true],
["\x7f\x7fêt", true],
["\x7f\x7fệt", true],
]);
});
});
@@ -0,0 +1,432 @@
/**
* @file AccountSettings.tsx
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
* sidebar shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
* admin's to change, on the Users page — never here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unknown) => {
const err = e as Error & { code?: string };
return err.code && t(`users:errors.${err.code}`, { defaultValue: "" })
? t(`users:errors.${err.code}`)
: err.message;
};
export function AccountSettings() {
const { t } = useTranslation("settings");
const [account, setAccount] = useState<ServerAccount | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const { updateUser } = useAuth();
const apply = useCallback(
(r: MyAccount) => {
if (r.mode !== "server") return;
setAccount(r);
// The sidebar shows the name and avatar: let them follow a save at once.
updateUser({
displayName: r.user.displayName,
email: r.user.email,
avatarAt: r.user.avatarAt ?? null,
});
},
[updateUser]
);
const load = useCallback(() => {
setLoadError(null);
api.me
.account()
.then(apply)
.catch((e: Error) => setLoadError(e.message));
}, [apply]);
useEffect(load, [load]);
if (loadError) {
return (
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
<button type="button" className="btn-ghost text-xs" onClick={load}>
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
</button>
</div>
);
}
if (!account) {
return <div className="card h-40 animate-pulse" data-testid="account-loading" />;
}
return (
<div className="space-y-6">
<ProfileCard account={account} onSaved={apply} />
<ProfileForm account={account} onSaved={apply} />
<PasswordForm />
<SignOutOthers logins={account.logins} onDone={load} />
</div>
);
}
/** The largest avatar the server takes (server/lib/avatars.js). */
const MAX_AVATAR_BYTES = 1024 * 1024;
function ProfileCard({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const { user } = account;
const shown = user.displayName || user.username;
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true);
try {
onSaved(await op());
toast.success(done);
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const pick = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again
if (!file) return;
if (file.size > MAX_AVATAR_BYTES) {
toast.error(t("account.avatar.tooBig"));
return;
}
void run(() => api.me.setAvatar(file), t("account.avatar.saved"));
};
return (
<section className="card p-5" data-testid="account-card">
<div className="flex items-center gap-4">
<div className="flex flex-shrink-0 flex-col items-center gap-1.5">
<UserAvatar name={shown} avatarAt={user.avatarAt} className="h-14 w-14 text-lg" />
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => fileRef.current?.click()}
disabled={busy}
title={t("account.avatar.change")}
aria-label={t("account.avatar.change")}
data-testid="account-avatar-change"
>
<Camera className="h-3.5 w-3.5" />
</button>
{user.avatarAt && (
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => void run(() => api.me.removeAvatar(), t("account.avatar.removed"))}
disabled={busy}
title={t("account.avatar.remove")}
aria-label={t("account.avatar.remove")}
data-testid="account-avatar-remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden"
onChange={pick}
data-testid="account-avatar-input"
/>
</div>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
<p className="truncate text-xs text-fg-muted">
@{user.username}
{user.email ? ` · ${user.email}` : ""}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
{t(`users:roles.${user.role}`)}
</span>
<span className="text-[11px] text-fg-muted">{t(`users:roleHint.${user.role}`)}</span>
</div>
</div>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
<Stat label={t("account.memberSince")}>
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
</Stat>
<Stat label={t("account.workspaces")}>{account.workspaces}</Stat>
<Stat label={t("account.sessions")}>{account.sessions}</Stat>
<Stat label={t("account.logins")}>{account.logins}</Stat>
<Stat label={t("account.lastLogin")}>
{account.lastLoginAt ? formatDateTime(account.lastLoginAt) : "—"}
</Stat>
</dl>
<p className="mt-3 text-[11px] text-fg-muted">{t("account.roleNote")}</p>
</section>
);
}
function ProfileForm({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const [displayName, setDisplayName] = useState(account.user.displayName ?? "");
const [email, setEmail] = useState(account.user.email ?? "");
const [busy, setBusy] = useState(false);
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
const body: { displayName?: string | null; email?: string | null } = {};
if (norm(displayName) !== (account.user.displayName ?? null))
body.displayName = norm(displayName);
if (norm(email) !== (account.user.email ?? null)) body.email = norm(email);
const changed = Object.keys(body).length > 0;
const save = async () => {
if (!changed || busy) return;
setBusy(true);
try {
onSaved(await api.me.updateAccount(body));
toast.success(t("account.profileSaved"));
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void save();
}
};
return (
<SettingsSection
id="account-profile"
title={t("account.profile")}
description={t("account.profileDescription")}
>
<div className="grid gap-3 sm:grid-cols-2" onKeyDown={onKeyDown}>
<Field label={t("users:displayName")}>
<input
className="input w-full text-sm"
value={displayName}
maxLength={100}
onChange={(e) => setDisplayName(e.target.value)}
aria-label={t("users:displayName")}
/>
</Field>
<Field label={t("users:email")}>
<input
type="email"
className="input w-full text-sm"
value={email}
maxLength={254}
onChange={(e) => setEmail(e.target.value)}
aria-label={t("users:email")}
/>
</Field>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!changed || busy}
onClick={() => void save()}
>
{t("account.save")}
</button>
</div>
</SettingsSection>
);
}
function PasswordForm() {
const { t } = useTranslation("settings");
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 ready = current.length > 0 && next.length > 0 && again === next && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.me.changePassword(current, next);
setCurrent("");
setNext("");
setAgain("");
toast.success(t("account.passwordChanged"));
} catch (e) {
setError(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<SettingsSection
id="account-password"
title={t("account.password")}
description={t("account.passwordDescription")}
>
<div className="grid gap-3 sm:grid-cols-3" onKeyDown={onKeyDown}>
<Field label={t("account.currentPassword")}>
<input
type="password"
className="input w-full text-sm"
value={current}
autoComplete="current-password"
onChange={(e) => setCurrent(e.target.value)}
aria-label={t("account.currentPassword")}
/>
</Field>
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={next}
autoComplete="new-password"
onChange={(e) => setNext(e.target.value)}
aria-label={t("account.newPassword")}
/>
</Field>
<Field label={t("account.confirmPassword")}>
<input
type="password"
className="input w-full text-sm"
value={again}
autoComplete="new-password"
onChange={(e) => setAgain(e.target.value)}
aria-label={t("account.confirmPassword")}
/>
</Field>
</div>
{(mismatch || error) && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error ?? t("account.mismatch")}
</p>
)}
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!ready}
onClick={() => void submit()}
>
{t("account.changePassword")}
</button>
</div>
</SettingsSection>
);
}
function SignOutOthers({ logins, onDone }: { logins: number; onDone: () => void }) {
const { t } = useTranslation("settings");
const [busy, setBusy] = useState(false);
const run = async () => {
const ok = await confirmDialog({
title: t("account.signOutOthersConfirm"),
message: t("account.signOutOthersBody"),
confirmLabel: t("account.signOutOthers"),
cancelLabel: t("common:cancel"),
destructive: true,
});
if (!ok) return;
setBusy(true);
try {
const r = await api.me.signOutOthers();
toast.success(t("account.signedOut", { count: r.ended }));
onDone();
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
return (
<SettingsSection
id="account-sessions"
title={t("account.sessionsTitle")}
description={t("account.sessionsDescription", { n: logins })}
>
<button
type="button"
className="btn-ghost text-status-danger disabled:opacity-50"
disabled={busy || logins <= 1}
onClick={() => void run()}
>
<LogOut className="h-4 w-4" /> {t("account.signOutOthers")}
</button>
</SettingsSection>
);
}
function Stat({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-[11px] text-fg-muted">{label}</dt>
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
@@ -0,0 +1,171 @@
/**
* @file AgentInstructionsSettings — Settings → AI tools → My agent
* instructions (issue #67): the account's own rules in markdown, put before
* every workspace's own instructions in each new Claude or Codex session of a
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
* failed load says so with a Retry instead of leaving a dead, empty editor —
* the editor stays read-only until the saved text is known, so a save can
* never overwrite instructions it did not load. **Choose file** reads a local
* Markdown/text file in the browser into the draft (nothing is uploaded until
* Save, and Discard brings the saved text back). "Applies to" lists only the
* installed, enabled tools whose panes the server says receive them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { FileUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api, type AgentInstructionsView } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { toast } from "../../lib/toast";
import { SettingsSection } from "./SettingsRow";
export function AgentInstructionsSettings() {
const { t } = useTranslation("settings");
const [saved, setSaved] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [max, setMax] = useState<number | undefined>();
const [toolIds, setToolIds] = useState<string[]>([]);
const { tools } = useAiTools();
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const fileInput = useRef<HTMLInputElement | null>(null);
const apply = (r: AgentInstructionsView) => {
setSaved(r.instructions);
setDraft(r.instructions);
setMax(r.max);
setToolIds(r.tools ?? []);
};
const load = () => {
setLoadError(null);
api.me
.instructions()
.then(apply)
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
};
useEffect(load, []);
async function save() {
setSaving(true);
try {
apply(await api.me.saveInstructions(draft));
toast.success(t("agentInstructions.saved"));
} catch (err) {
toast.error(
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
);
} finally {
setSaving(false);
}
}
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again after a Discard
if (!file) return;
const text = await file.text();
if (max && text.length > max) {
toast.error(
t("agentInstructions.fileTooBig", {
name: file.name,
size: text.length.toLocaleString(),
max: max.toLocaleString(),
})
);
return;
}
setDraft(text);
}
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
const ready = saved !== null;
const dirty = ready && draft !== saved;
return (
<SettingsSection
id="agent-instructions"
title={t("agentInstructions.title")}
description={t("agentInstructions.description")}
>
<div className="pt-1">
{appliesTo.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
<span>{t("agentInstructions.appliesTo")}</span>
{appliesTo.map((x) => (
<span
key={x.id}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
>
<AiToolIcon tool={x} className="h-4 w-4" />
{x.label}
</span>
))}
</div>
)}
<textarea
aria-label={t("agentInstructions.title")}
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
rows={8}
spellCheck={false}
placeholder={t("agentInstructions.placeholder")}
value={draft}
maxLength={max}
disabled={!ready}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="mt-3 flex items-center justify-between gap-3">
{loadError ? (
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
<span className="truncate">
{t("agentInstructions.loadFailed", { message: loadError })}
</span>
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
{t("common:retry")}
</button>
</p>
) : (
<span className="text-[11px] tabular-nums text-fg-muted">
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
</span>
)}
<div className="flex shrink-0 items-center gap-2">
<input
ref={fileInput}
type="file"
accept=".md,.markdown,.txt,text/markdown,text/plain"
className="hidden"
data-testid="agent-instructions-file"
onChange={(e) => void pickFile(e)}
/>
<button
type="button"
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
disabled={!ready || saving}
onClick={() => fileInput.current?.click()}
>
<FileUp className="h-3.5 w-3.5" />
{t("agentInstructions.chooseFile")}
</button>
{dirty && !saving && (
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
{t("agentInstructions.discard")}
</button>
)}
<button
type="button"
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
disabled={saving || !dirty}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
</div>
</div>
</SettingsSection>
);
}
@@ -1,8 +1,13 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the per-user
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the
* @description Settings → Appearance (`/settings/appearance`), grouped so
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`); then the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
@@ -17,7 +22,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings";
import { Select } from "../Select";
@@ -31,6 +36,16 @@ import {
writeTerminalFont,
type TerminalFont,
} from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
import {
TERMINAL_FONT_SIZES,
UI_FONT_SIZES,
readTerminalFontSize,
readUiFontSize,
writeTerminalFontSize,
writeUiFontSize,
type UiFontSize,
} from "../../lib/fontSize";
export const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
@@ -79,6 +94,9 @@ export function AppearanceSettings() {
const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const [termFont, setTermFont] = useState(readTerminalFont);
const [termSize, setTermSize] = useState(readTerminalFontSize);
const [uiSize, setUiSize] = useState(readUiFontSize);
const [allTabs, setAllTabs] = useState(readAllTabs);
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
setTermFont(font);
@@ -99,15 +117,11 @@ export function AppearanceSettings() {
return (
<div className="space-y-6">
<ChatUiSettings />
<SettingsSection
id="language"
title={t("language.title")}
description={t("language.description")}
>
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
<SettingsRow
id="language"
label={t("language.label")}
description={t("language.description")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
@@ -120,16 +134,34 @@ export function AppearanceSettings() {
/>
}
/>
</SettingsSection>
<SettingsSection
id="terminal-font"
title={t("terminalFont.title")}
description={t("terminalFont.description")}
>
<SettingsRow
label={t("terminalFont.label")}
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
id="text-size"
label={t("textSize.label")}
description={t("textSize.description")}
control={
<SettingsSegmented
ariaLabel={t("textSize.label")}
value={String(uiSize)}
onChange={(v) => {
const size = Number(v) as UiFontSize;
writeUiFontSize(size);
setUiSize(size);
}}
options={UI_FONT_SIZES.map((size) => ({
value: String(size),
label: t(`textSize.names.${size}`),
}))}
/>
}
/>
<SettingsRow
id="terminal-font"
label={t("terminalFont.title")}
description={
termFont === "system"
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
: t("terminalFont.description")
}
control={
// A dropdown: five font names overflow a segmented row.
<Select<TerminalFont>
@@ -143,17 +175,57 @@ export function AppearanceSettings() {
/>
}
/>
<SettingsRow
label={t("terminalFont.sizeLabel")}
control={
<Select
label={t("terminalFont.sizeLabel")}
value={String(termSize)}
onChange={(v) => {
writeTerminalFontSize(Number(v));
setTermSize(Number(v));
}}
options={TERMINAL_FONT_SIZES.map((size) => ({
value: String(size),
label: `${size}px`,
}))}
/>
}
/>
<p
data-testid="terminal-font-preview"
aria-label={t("terminalFont.preview")}
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
// In px like xterm's cells: the interface's text size does not scale it.
style={{
fontFamily: TERMINAL_FONTS[termFont],
fontSize: termSize,
fontVariantLigatures: "none",
}}
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
</SettingsSection>
<SettingsSection
id="sessions-view"
title={t("sessionsView.title")}
description={t("sessionsView.description")}
>
<ChatUiSettings />
<SettingsSwitchRow
id="workspace-tabs"
label={t("workspaceTabs.label")}
description={t("workspaceTabs.labelDesc")}
checked={allTabs}
onChange={(on) => {
writeAllTabs(on);
setAllTabs(on);
}}
/>
</SettingsSection>
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow
@@ -0,0 +1,45 @@
/**
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
* admin's server-wide switch that allows or blocks installing a shared skill
* or plugin from the Hub into a user's own account (`GET/PUT
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
* or for anyone the server does not let manage it (`canManage`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ArtifactSettings } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ArtifactsHubSettings() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
useEffect(() => {
api.artifacts
.settings()
.then(setSettings)
.catch(() => setSettings(null));
}, []);
if (!settings?.serverMode || !settings.canManage) return null;
return (
<SettingsSection id="artifacts-hub" title={t("title")}>
<SettingsSwitchRow
label={t("installsSwitch")}
description={t("installsSwitchHint")}
checked={settings.installsEnabled}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
</SettingsSection>
);
}
@@ -1,7 +1,8 @@
/**
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
* @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
* (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
* Desktop (`PaneChat`) instead of its terminal. A row, not a card: it sits in
* the Sessions section beside the workspace-tabs switch. Saved per user on the server (`lib/chatUi.ts`), so each
* account on a shared dashboard keeps its own choice on any browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -9,25 +10,22 @@
import { useTranslation } from "react-i18next";
import { saveChatUi, useChatUi } from "../../lib/chatUi";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
import { SettingsSwitchRow } from "./SettingsRow";
export function ChatUiSettings() {
const { t } = useTranslation("settings");
const chatUi = useChatUi();
return (
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
<SettingsSwitchRow
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
)
)
}
/>
</SettingsSection>
<SettingsSwitchRow
id="chat-ui"
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
)
}
/>
);
}
@@ -0,0 +1,911 @@
/**
* @file GitIdentitySettings.tsx
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
* commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
* since a member's commits fail without one; a new SSH key is labelled with
* that email), the SSH
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
* it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
* each tied to the one key it uses (the SSH part is split into tabs: one per
* kind of host, the extra keys, the other hosts) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Cloud,
Copy,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate";
import { PageTabs } from "../PageTabs";
import { Switch } from "../Switch";
const INPUT =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
// The regions AWS CodeCommit runs in.
const AWS_REGIONS = [
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"sa-east-1",
"me-south-1",
"af-south-1",
"il-central-1",
];
function Step({ n, children }: { n: number; children: ReactNode }) {
return (
<div className="flex gap-2 text-[11px] text-fg-secondary">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
{n}
</span>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
function Code({ children }: { children: ReactNode }) {
return (
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
{children}
</code>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
*/
function KeyCard({
title,
sub,
type,
k,
id,
busy,
onGenerate,
onRemove,
children,
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
k: Key | undefined;
id: GitIdentity;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
return (
<section aria-label={title} className="space-y-2 rounded border border-border p-2.5">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
{k ? (
<>
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
</Step>
{children}
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [form, setForm] = useState<{ host: string; username: string; token: string } | null>(null);
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
// null = not edited: the field shows the saved value, else the account's.
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
// null = not picked: opens on the first kind of host that has a key.
const [sshTab, setSshTab] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setId(await api.gitIdentity.get());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
setBusy(true);
setError(null);
try {
setId(await fn());
if (done) toast.success(done);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return false;
} finally {
setBusy(false);
}
};
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// An account may hold one of each; an older server answers `ssh` only.
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
// The default key of a type (a named key sits beside it).
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
};
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
const makeNamed = async () => {
if (!newKey) return;
const ok = await run(
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
const removeNamed = async (keyId: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKeyById(keyId));
};
const generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type);
if (
taken &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
};
const removeKey = async (type: "ed25519" | "rsa") => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKey(type));
};
const saveLogin = async () => {
if (!form) return;
// A bare host is the usual entry; a pasted URL is reduced to its host.
let host = form.host.trim();
try {
if (/^https?:\/\//i.test(host)) host = new URL(host).host;
} catch {
/* the server answers EBADHOST */
}
const ok = await run(
() =>
api.gitIdentity.saveHttps({ host, username: form.username.trim(), password: form.token }),
t("gitCreds.saved", { host })
);
if (ok) setForm(null);
};
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
// one host per region) gets its own form under the RSA key; anything else
// is the "other hosts" form.
const sshUsers = id?.sshUsers ?? [];
const awsRows = sshUsers.flatMap((r) => {
const m = CODECOMMIT_RE.exec(r.host);
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
if (m?.[1]) host = m[1];
const alias = other.alias.trim().toLowerCase();
const ok = await run(
() =>
api.gitIdentity.setSshUser({
host,
user: other.user.trim(),
...(alias ? { alias } : {}),
...(other.key ? { key: other.key } : {}),
}),
t("gitIdentity.otherSaved", { host: alias || host })
);
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
};
/** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="select !py-1 !text-xs"
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id && (!only || k.type === only))
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
);
const removeLogin = async (host: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeLoginConfirm", { host }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeHttps(host));
};
if (!id) {
return error ? (
<p className="text-xs text-status-danger">{error}</p>
) : (
<p className="text-xs text-fg-muted">{t("gitCreds.loading")}</p>
);
}
const member = id.account === "member";
const authorShown = author ?? {
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
};
const authorUnset = !id.author.name || !id.author.email;
const authorDirty =
authorUnset ||
authorShown.name.trim() !== (id.author.name ?? "") ||
authorShown.email.trim() !== (id.author.email ?? "");
// The SSH keys, one tab per kind of host — every section at once was a wall.
const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
...(named.length > 0 || id.writable
? [
{
id: "more",
label: named.length
? `${t("gitIdentity.tabMore")} (${named.length})`
: t("gitIdentity.tabMore"),
Icon: KeySquare,
},
]
: []),
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []),
];
const activeSshTab =
sshTab && sshTabs.some((x) => x.id === sshTab)
? sshTab
: !keyOf("ed25519") && keyOf("rsa")
? "aws"
: "git";
const saveAuthor = async () => {
const ok = await run(
() =>
api.gitIdentity.saveAuthor({
name: authorShown.name.trim(),
email: authorShown.email.trim(),
}),
t("gitIdentity.authorSaved")
);
if (ok) setAuthor(null);
};
return (
<div className="space-y-4">
<p className="text-xs text-fg-muted">
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
</p>
{error && (
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
<form
className="space-y-2 rounded-lg border border-border p-3"
onSubmit={(e) => {
e.preventDefault();
void saveAuthor();
}}
>
<h3 className="text-xs font-semibold text-fg-primary">{t("gitIdentity.authorTitle")}</h3>
{authorUnset && (
<p className="text-[11px] text-status-warning">{t("gitIdentity.authorUnset")}</p>
)}
<div className="grid gap-2 sm:grid-cols-2">
<input
aria-label={t("gitIdentity.authorName")}
placeholder={t("gitIdentity.authorName")}
value={authorShown.name}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, name: e.target.value })}
className={INPUT}
/>
<input
type="email"
aria-label={t("gitIdentity.authorEmail")}
placeholder="name@example.com"
value={authorShown.email}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, email: e.target.value })}
className={INPUT}
/>
</div>
{id.writable && (
<button
type="submit"
disabled={busy || !authorDirty || !authorShown.name.trim() || !authorShown.email.trim()}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
)}
</form>
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
<PageTabs
ariaLabel={t("gitIdentity.sshTitle")}
tabs={sshTabs}
activeId={activeSshTab}
onSelect={setSshTab}
>
{activeSshTab === "git" && (
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
)}
{activeSshTab === "aws" && (
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `${r.alias} · ` : ""}
{r.region} · {r.user} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={aws.alias}
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
)}
</KeyCard>
)}
{activeSshTab === "more" && (
<section
aria-label={t("gitIdentity.moreKeysTitle")}
className="space-y-2 rounded border border-border p-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.moreKeysTitle")}
</div>
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
</div>
{id.writable && !newKey && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setNewKey({ name: "", type: "rsa" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.newKey")}
</button>
)}
</div>
{newKey && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
onSubmit={(e) => {
e.preventDefault();
void makeNamed();
}}
>
<input
aria-label={t("gitIdentity.keyName")}
placeholder={t("gitIdentity.keyNamePlaceholder")}
autoComplete="off"
autoFocus
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.keyType")}
value={newKey.type}
onChange={(e) =>
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
}
className="select !py-1 !text-xs"
>
<option value="rsa">RSA 4096 (AWS)</option>
<option value="ed25519">ED25519</option>
</select>
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
{t("gitIdentity.generate")}
</button>
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
{t("gitCreds.cancel")}
</button>
</form>
)}
{named.map((k) => (
<div
key={k.id}
className="space-y-1 rounded border border-border-light p-2"
data-testid="git-named-key"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-medium text-fg-primary">
{k.name}
</span>
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{k.type === "rsa" ? "RSA" : "ED25519"}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
onClick={() => void removeNamed(k.id!)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
value={k.publicKey}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
/>
</div>
))}
</section>
)}
{activeSshTab === "other" && (
<section
aria-label={t("gitIdentity.otherTitle")}
className="space-y-1.5 rounded border border-border p-2.5"
>
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.otherTitle")}
</div>
<div className="space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => (
<div key={r.alias ?? r.host} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `ssh://${r.alias}/ → ` : ""}
{r.user}@{r.host} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveOther();
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="git.example.com"
value={other.host}
onChange={(e) => setOther({ ...other, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.otherUser")}
placeholder={t("gitIdentity.otherUser")}
autoComplete="off"
value={other.user}
onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={other.alias}
onChange={(e) => setOther({ ...other, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(other.key, (key) => setOther({ ...other, key }))}
<button
type="submit"
className={BTN}
disabled={busy || !other.host.trim() || !other.user.trim()}
>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</div>
</section>
)}
</PageTabs>
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
</div>
{member && (
<div className="space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.httpsTitle")}
</h3>
<button
type="button"
className={BTN}
onClick={() => setForm({ host: "", username: "", token: "" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitCreds.add")}
</button>
</div>
{form && (
<form
className="grid gap-2 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void saveLogin();
}}
>
<input
aria-label={t("gitCreds.fieldHost")}
placeholder="github.com"
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitCreds.fieldUser")}
placeholder={t("gitCreds.fieldUser")}
autoComplete="off"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className={INPUT}
/>
<input
type="password"
aria-label={t("gitCreds.fieldToken")}
placeholder={t("gitCreds.fieldToken")}
autoComplete="new-password"
value={form.token}
onChange={(e) => setForm({ ...form, token: e.target.value })}
className={INPUT}
/>
<div className="flex gap-2 sm:col-span-3">
<button
type="submit"
disabled={busy || !form.host.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
<button type="button" onClick={() => setForm(null)} className={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
{id.https.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noLogins")}</p>
) : (
<ul className="divide-y divide-border text-xs">
{id.https.map((c) => (
<li key={c.host} className="flex items-center gap-2 py-1.5">
<span className="font-mono text-[11px] text-fg-primary">{c.host}</span>
<span className="flex-1 text-fg-secondary">{c.username}</span>
<button
type="button"
aria-label={`${t("gitIdentity.remove")} ${c.host}`}
title={t("gitIdentity.remove")}
disabled={busy}
onClick={() => void removeLogin(c.host)}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -20,6 +20,7 @@ import { Checkbox } from "../Checkbox";
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import {
ArrowRight,
DollarSign,
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
*/
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
const { t } = useTranslation("settings");
const href = useSettingsHref();
return (
<div className="card flex items-center justify-between gap-4 p-5">
<div className="min-w-0">
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
</div>
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
<Link to={href(`pricing#pricing-${vendor}`)} className="btn-ghost shrink-0 text-xs">
{t("pricingLink.open")}
<ArrowRight className="h-3.5 w-3.5" />
</Link>
@@ -37,15 +37,19 @@ export function SettingsRow({
description,
control,
alignTop = false,
id,
}: {
label: ReactNode;
description?: ReactNode;
control: ReactNode;
alignTop?: boolean;
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
id?: string;
}) {
return (
<div
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`}
>
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
checked,
onChange,
disabled = false,
id,
}: {
label: string;
description?: ReactNode;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
id?: string;
}) {
return (
<SettingsRow
id={id}
label={label}
description={description}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
@@ -0,0 +1,131 @@
/**
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, asks before signing
* the other devices out, and uploads/removes the avatar.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const me = vi.hoisted(() => ({
account: vi.fn(),
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
setAvatar: vi.fn(),
removeAvatar: vi.fn(),
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { me } };
});
const confirm = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog: confirm }));
import { AccountSettings } from "../AccountSettings";
const account = {
mode: "server" as const,
user: {
id: 2,
username: "mia",
role: "developer" as const,
disabled: false,
displayName: "Mia Tran",
email: null,
createdAt: "2026-10-01T00:00:00.000Z",
},
workspaces: 3,
sessions: 12,
logins: 2,
lastLoginAt: null,
};
beforeEach(() => {
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
me.signOutOthers.mockResolvedValue({ ended: 1 });
confirm.mockReset().mockResolvedValue(true);
});
describe("AccountSettings", () => {
it("uploads a picked avatar and removes it again", async () => {
const ue = userEvent.setup();
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
me.setAvatar.mockResolvedValue(withAvatar);
me.removeAvatar.mockResolvedValue(account);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
await ue.upload(screen.getByTestId("account-avatar-input"), file);
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-14T00:00:00Z"
);
await ue.click(screen.getByTestId("account-avatar-remove"));
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
});
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
expect(card.textContent).toContain("Mia Tran");
expect(card.textContent).toContain("@mia");
expect(card.textContent).toContain("Developer");
expect(card.textContent).toContain("12");
});
it("saves only a changed field", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
await ue.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(me.updateAccount).toHaveBeenCalledWith({ email: "mia@x.vn" }));
});
it("refuses two different new passwords", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-999");
expect(screen.getByText("The two new passwords differ.")).toBeTruthy();
const button = screen.getByRole("button", { name: "Change password" });
expect(button.hasAttribute("disabled")).toBe(true);
expect(me.changePassword).not.toHaveBeenCalled();
});
it("changes the password with the current one", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
await ue.click(screen.getByRole("button", { name: "Change password" }));
await waitFor(() =>
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
);
});
it("asks before signing the other devices out", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByRole("button", { name: "Sign out other devices" }));
expect(confirm).toHaveBeenCalled();
await waitFor(() => expect(me.signOutOthers).toHaveBeenCalled());
});
});

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