Compare commits

...

46 Commits

Author SHA1 Message Date
nntrivi2001 df15789982 fix(9router): a sudo update gives root's npm its own cache
The AgentHub unit makes /root inaccessible (InaccessiblePaths=), so root's
npm failed at mkdir /root/.npm with ENOENT (exit 254). A sudo run now passes
--cache /var/cache/agenthub-9router-npm; the sudoers rule names that argv.
2026-10-26 17:12:02 +07:00
nntrivi2001 272a3e7d6e chore: v2.8.27 — Update 9router from Settings → AI Proxy 2026-10-26 17:08:09 +07:00
nntrivi2001 569b68ec98 feat(ai-proxy): Update 9router button — shut down, npm update, relaunch hidden
Settings → AI Proxy gains an admin-only Update 9router button beside
Config 9router. One in-memory job (server/lib/nine-router-update.js) stops
9router (its user unit, else this account's own 9router processes), runs
npm install -g --prefix <its prefix> 9router@latest (sudo -n only when
sudoers already allows it, probed before anything stops), then relaunches
it hidden only if it was running. A failed update still relaunches the old
version. Nothing in the request names a command, path or package.
2026-10-26 17:00:53 +07:00
nntrivi2001 631ea96f19 chore: v2.8.26 — React 19 / Express 5 / xterm 6, bigger file drops, hidden-tab terminal size fix 2026-10-26 16:36:49 +07:00
nntrivi2001 dce32b96b7 fix(drop): stream every dropped file to disk; no local size cap
- POST /api/run/drop-file pipes the body to its file as it arrives in every
  case — a member's (S3) through `write-as … replace` with the bytes on
  stdin — instead of buffering up to 20 MB in memory for a remote browser.
- Remote limit (server mode, a LAN browser) 20 MB → 500 MB, as a Files upload.
- A loopback browser in local mode has no cap any more, only the data dir's
  free space less a 1 GB reserve; ETOOBIG's max_bytes is what still fits, so
  the UI's "at most N MB" stays true.
- drop-image keeps its 20 MB in-memory limit, now checked on its own.
- fake-sudo gains a spawn for run-as.__setSpawnImpl.
2026-10-26 16:36:29 +07:00
nntrivi2001 a0cd808456 fix(terminal): a pane on a hidden tab never shrinks its tmux window
A terminal mounted on a hidden tab sits in a 0x0 box; its first fit shrank
xterm to 12x6 and the attach carried that size to tmux, so every background
pane was squeezed (Claude's TUI redrawn at 12 columns). It now neither fits
nor reports a size until shown, attaches with no size, and the server spawns
such an attach at the window's own size (tmux.windowSize). A shown tab
reports its size even when the fit did not change xterm's.

Settings > Agents shows the host's login profiles to staff only: a developer
got two 403s from reads that were never theirs.
2026-10-26 14:53:29 +07:00
nntrivi2001 e3745d7504 fix(build): target ES2021 so esbuild keeps xterm 6's requestMode variable
Vite's es2020 default made esbuild 0.25 lower `let r; …(r||={})` to
`void 0||(i={})` without the declaration, so every DECRQM query tmux sends
threw "i is not defined" inside the terminal's parser. Found by a headless
smoke run of the preview build.
2026-10-26 14:43:01 +07:00
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00
nntrivi2001 cc2ece94c3 fix(ui): drop the git clone hints from Git identity and the background toast on closing the update dialog 2026-10-26 14:22:18 +07:00
nntrivi2001 6b602c76b8 chore: v2.8.25 — per-user Today, Alt+V image paste on Windows, local paste/drop by path 2026-10-26 11:18:05 +07:00
nntrivi2001 f2f1cd9b6c fix: per-user Today, native paste and drop on a local machine
- Today's spend in the status bar and the Claude Code statusline is the
  caller's own (`GET /api/pricing/cost?mine=1`, scope.ownSessionWhere), not
  every user's sum for an admin or analyst.
- A screenshot pasted into a local claude pane is sent as Claude Code's own
  image-paste key: Alt+V on Windows (Ctrl+V did nothing there), ^V elsewhere,
  and only when the machine can read its clipboard (wl-paste/xclip + a
  display on Linux), else it is uploaded (GET /api/run/native-paste {key}).
- A file manager's files pasted or dropped on a local browser go in by their
  own paths, read from the OS clipboard or the file manager's selection
  (server/lib/file-manager.js, POST /api/run/clipboard-files and
  /selected-files): no upload, no size limit. A copied image file is pasted
  by path, text that comes with a picture of it (spreadsheet cells) is
  pasted as text, Windows paths go in as `C:/…` mentions.
- Tab titles for Snapshot, Artifacts, Files and Administration.
- An npm tool AgentHub installed under ~/.local is updated there, not by its
  own updater into npm's global prefix.
2026-10-26 11:18:05 +07:00
nntrivi2001 c59e196e41 chore: v2.8.24 — sub-agent edits in Edited N files, OpenCode/Grok dropped, Artifacts list view and skill sync
Edited N files (#86): a turn's card counts the files its subagents wrote
or edited (read from their own transcripts, nested ones folded in), and a
turn longer than the chat's first page keeps the edits made before the
page cut. A non-Claude transcript page honours its limit again.

AI tools: OpenCode and Grok Build left the catalog (opencode-usage.js
gone; a Grok payload on the Claude hook route is dropped), and so did the
leftover Kimi Code quota reader.

Artifacts: a list view, skill packs and badges only by exception; a
shared skill follows its owner's folder, and an installer whose copy
differs is offered Update.
2026-10-26 09:46:45 +07:00
nntrivi2001 72aef2426c feat(artifacts): a shared skill follows its owner's folder; installers get Update
A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> changed nothing there, and an installer had to
uninstall and install again to get a newer version.

- syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row
  taken from a folder — the owner's ~/.claude/skills/<name>, or the
  workspace's .claude/skills/<name> — is brought up to it. Only a folder
  whose names/sizes/mtimes changed is read again, with a pick's checks (no
  symlink, no secret, <= 5 MB, a member's files as theirs); a change is a
  NEW snapshot, the old one dropped, and an artifact_changed broadcast.
- artifacts.version (snapshot content hash) and source_hash (the folder's
  when last taken, additive columns): an upload or a Hub edit records the
  folder as it is then, so only a LATER folder change replaces it.
- A list row's updateAvailable: installed, and the viewer's copy differs
  from version. The page shows Update (a pack: Update N); install with
  {update: true} replaces the copies (old ones moved aside) and is not
  counted as another install.
2026-10-26 09:34:21 +07:00
nntrivi2001 244f999538 feat(artifacts): list view, skill packs, badges by exception
The page showed every artifact as a tall card with a blue Install and four
text actions, a Shared badge on nearly all of them and 12 cards per section,
so 26 artifacts filled ~1900px (5000px on a phone).

- List view by default (Grid kept, a per-browser choice): one row per
  artifact — kind icon, name, one line of description, owner and installs,
  ONE primary action, everything else in its ⋯ menu / right-click.
- Three or more skills of one owner sharing a name prefix (sg-*, vp-*) are
  one pack row: open it for its skills, or Install all / Install N more
  after one confirmation (a name already there is left alone).
- Badges only for the exception: Private (not shared), Hub only, Auto.
- No per-section paging: 30 entries, then Show more.
- Scope tabs under the title; category chips scroll on a phone.
- Another user's private row (seen by an admin) gets its own section
  instead of sitting under Shared by others.
- "1 installs" → "1 install".
2026-10-26 09:34:10 +07:00
nntrivi2001 127da516ac chore(quota): drop the Kimi Code quota reader (Kimi already left the catalog)
Redone on v2.8.22 from 3179d25 (feat/grok-usage, never merged).
2026-10-26 09:31:37 +07:00
nntrivi2001 c0832182f2 feat(ai-tools): drop OpenCode and Grok Build
- opencode.json / grok.json leave the built-in catalog; opencode-usage.js,
  its poller, transcript branch and table creation go with it (existing
  tables and session rows stay as history).
- A Grok payload reaching the Claude hook route (Grok runs the Claude hooks
  itself) is dropped instead of recorded as tool = 'grok'.
- quota.js no longer reads Grok or OpenCode Go logins.
- Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
2026-10-26 09:31:30 +07:00
nntrivi2001 9ea7758abb fix(transcript): a non-Claude page honours its limit again (#86)
`carry?.drop(messages.shift())` skips its argument when `carry` is null —
optional chaining short-circuits the whole call — so a Codex, omp, Cursor
or Antigravity page never shifted its sliding window and returned the
whole journal (codex-transcript.test.js: 8 messages for ?limit=2). Shift
first, then hand the dropped message to the carry.
2026-10-26 08:35:39 +07:00
nntrivi2001 f51a6a895e fix(chat): keep a long turn's Edited N files card whole across the page cut (#86)
The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the
chat loads the newest 50 messages, and the turn's other Write
(rbac_routes_test.go, +176) was in the part before the page.

- turnCarry (agent-edits.js): every message the route's sliding window
  drops goes through it; it keeps the file changes since the last prompt it
  dropped (a dropped call whose result is on the page, and dropped
  subagents, included).
- A Claude page that starts mid-turn answers them as `turn_edits`; the
  chat keeps the one from the page that holds its oldest message and adds
  it to that turn's card.

Files a turn changed through Bash (python/sed writing a file) are still not
listed: nothing in the transcript says which files a command wrote.
2026-10-26 08:32:45 +07:00
nntrivi2001 5ccbaf5c23 fix(chat): count a subagent's edits in the turn's Edited N files card (#86)
A skill that hands its work to subagents (vp-auto) showed "Edited 1 file"
for a turn that changed 4: a subagent's Write/Edit calls are in its own
agent-<id>.jsonl, so the card only saw the parent's.

- server/lib/agent-edits.js reads a subagent's transcript (readLines, a
  member's only as theirs) for the files it wrote or edited with their +/-
  lines, nested subagents folded in (3 levels), cached by size + mtime.
- The transcript route puts them on each Claude Agent/Task result as
  `edits`; the card adds them to the turn's own edits. A file only a
  subagent wrote is listed, never an artifact card (its versions are
  rebuilt from this transcript's inputs, which a subagent's are not).
2026-10-26 08:32:45 +07:00
nntrivi2001 c8bc4a95a9 chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
2026-10-23 16:41:45 +07:00
nntrivi2001 65a001dad4 fix(lanes): cloning into a deleted workspace's folder re-attaches it instead of failing UNIQUE
A workspace record outlives its folder (deleted from the File Explorer or a
shell). Cloning the same repo into that path again ran `git clone`, then
createLane hit idx_lanes_cwd_tool and answered 500 "UNIQUE constraint failed:
lanes.cwd, lanes.tool", leaving the new clone with no workspace on it.

POST /api/lanes/clone now looks for the caller's own workspace at exactly
that folder and tool and answers 200 { lane, created: false, path }, like
POST /ensure. Another user's workspace there is still refused before the
clone (folderTakenByOther), and a nested path still makes its own workspace.
2026-10-23 16:28:18 +07:00
nntrivi2001 572d615d4e chore: v2.8.22 — Users today/7/30-day spend counts every tool, update chip follows a running update
- Users: today / 7 / 30-day spend read only token_usage_hourly, which holds
  Claude transcript hours only — omp/Codex/Antigravity/Cursor sessions,
  sub-agent files, baselines and long-context pricing were missing. Now
  booked by hourlyTokenRows + calculateCost like the cost routes.
- Update: a page adopts a plugin update the server reports as running
  (after a reload, another tab, or one started from an older bundle), and
  the progress chip shows whenever an update runs and the modal is not
  visible — also when the status stops offering it mid-run.
2026-10-23 11:26:12 +07:00
nntrivi2001 9dd97a369c chore: v2.8.21 — update progress steps, clone in background, extra SSH keys inline, today's spend
- Plugin update reports its running step (plugin-update-progress.json →
  plugin_update_progress); a hidden update or clone leaves a progress chip.
- Clone here can run in the background.
- Git identity: extra SSH keys are added inline per host (no More keys tab).
- Users: spend today (viewer's local day, ?tz_offset=).
- Prettier on the files touched since v2.8.20.
2026-10-23 10:44:11 +07:00
nntrivi2001 62b4520507 feat(artifacts): capture Claude Docs docs and session documents, claim unowned rows
- A Claude Docs `batch` that creates a doc is captured like an `Artifact`
  publish, titled by the doc's name and linked by its own frame URL.
- At Stop/SessionEnd, each pptx/docx/xlsx/pdf/html a workspace session
  created or changed is snapshotted into its owner's library (`auto`);
  an unchanged file is skipped, a changed one refreshes its snapshot.
- Server mode: a page with no owner carries a suggested owner (the
  workspace or home its session ran in). The suggested user claims or
  declines it; an admin claims or assigns any. New tab Unowned /
  Is this yours? on the Artifacts page; all three actions audited.
2026-10-23 08:33:55 +07:00
nntrivi2001 241e19b713 chore: v2.8.20 — account pool per tool with groups and a Cursor pool, update dialog closes while updating 2026-10-22 17:02:08 +07:00
nntrivi2001 685eb748e8 feat: account pool per tool with groups and a Cursor pool; update runs in the background
- The update dialog can be closed while an update applies: it keeps running,
  the page still reloads itself when the new server is up, and a failure
  re-opens the dialog with its error.
- Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI,
  Oh My Pi, and every other installed tool, which says it has no pool),
  accounts as cards with 5-hour and weekly quota shown as what is LEFT plus
  their reset times, and the saved profiles of Settings → AI tools →
  Profiles as cards with "Share to pool".
- Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to
  a cursor-agent pane only through tmux -e CURSOR_API_KEY;
  `agenthub accounts add <name> --tool cursor`.
- Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's
  members run only on its accounts of each tool, a user in no group on the
  accounts no group lists, a pin still wins. account_pins loses its tool
  CHECK (rebuilt, pins kept); three new tables.
- Oh My Pi login sharing can be switched from its pool tab too.
2026-10-22 17:02:08 +07:00
nntrivi2001 44426faf16 chore: v2.8.19 — avatar from a link the browser loads, Files groups workspaces by owner 2026-10-22 14:34:50 +07:00
nntrivi2001 26952ae0d9 feat: avatar by link, loaded by the browser; Files nests workspaces under owners' homes
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead
  of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302
  to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no
  localhost or private/reserved IP literal. Upload and link replace each other.
- Settings -> Account first loads the link as an embedded image and refuses
  one this browser cannot show: a picture behind a login (Mattermost's
  /api/v4/users/<id>/image) gets no cookie cross-site and 401s.
- Files sidebar: each workspace under its owner's home, chevrons remembered
  per browser (agenthub.files.homesOpen); ownerless ones stay flat.
2026-10-22 14:34:50 +07:00
nntrivi2001 649f0599f8 chore: v2.8.18 — pasted file-manager copies attach by path locally, copy/IME/stale-reply fixes, react-router 7, full OpenAPI 2026-10-22 13:58:46 +07:00
nntrivi2001 8d2765313c fix(chat): a file-manager copy pasted on a local browser attaches by its own path
Chrome gives a PASTE of a file manager's copy (Ctrl+C in Nautilus, Ctrl+V in
the chat) the files and their paths as plain text, but no text/uri-list —
only a drag carries that — so localCandidates found no path and every such
file was uploaded, and refused past the upload limit. Plain-text paths (or
file:// lines) whose names match the pasted files one for one are now
candidates too; sameFilesHere still has the server confirm each is the same
file (size) on this machine, so server mode and remote browsers upload as
before. The prompt box also attaches a file:// text paste with no file by
path, as the terminal already did (copiedFilePaths moved to promptAttach).
2026-10-22 13:47:17 +07:00
nntrivi2001 57f02f0d89 docs: OpenAPI and API.md cover every route; drop removed endpoints; guard config writes
- openapi-extra/run.js describes tmux pane runs only: the removed
  POST /api/run/{id}/message and stream-json run_stream/run_input_ack are gone.
- 108 operations added to OpenAPI (lanes, run, settings, reports, cc-config,
  pets, ai-proxies, tool-profiles, ai-tools, snapshots, locks, ...);
  openapi.yaml regenerated.
- docs/API.md: 74 routes added, 4 endpoints that no longer exist removed.
- Role is `developer`, not `member` (CLI.md, DATABASE.md, OVERVIEW.md);
  `agenthub update` -> `update-check`; help lists `server install-system|tools`
  and `users provision`; ARCHITECTURE.md's stale paths and names fixed.
- cc-config/codex-config writes carry sameOriginGuard (an MCP server or plugin
  is code Claude runs later), with a test; `command -v` takes its name as $1.
2026-10-22 12:17:18 +07:00
nntrivi2001 50ac0f0daf fix(client): copies over plain HTTP, Vietnamese IME Enter, stale replies; react-router 7
- Every Copy goes through copyText: on http://<lan-ip> navigator.clipboard
  is undefined, so the push token (shown once), snapshot links, tool paths and
  hosts copied nothing, silently or with a TypeError.
- Inputs that submit on Enter use isSubmitEnter, so committing an IME
  composition (Vietnamese) no longer saves a half-typed pet name, jumps the
  settings search, or submits a cut-off plugin/MCP/marketplace spec.
- LaneChangesPanel, Workspace features/proof and AiToolConfig drop a reply
  that lands after the next request started: opening file B before file A's
  read returned showed (and let the editor save) A's text under B's header.
- Error-state Retry buttons spin through useSpin; ImageLightbox uses useDialog.
- react-router-dom 6.30 -> 7.18.4 (GHSA-wrjc-x8rr-h8h6, GHSA-337j-9hxr-rhxg);
  screen snapshots gain only Link's data-discover attribute.
- StatusBar test follows v2.8.17's "Settings → AI tools → Profiles" text.
2026-10-22 12:17:18 +07:00
nntrivi2001 fa74cce3fc chore: v2.8.17 — every AI tool's login profiles in one Settings section, linked to the account pool 2026-10-22 11:11:43 +07:00
nntrivi2001 3113479cd2 feat(settings): every AI tool's login profiles in one Settings section, linked to the account pool
Settings → AI tools → Profiles holds one tab per tool: Claude Code (the
cc-profiles panel, moved out of the Claude Code page's modal — its button
now links here), Codex (the whole $CODEX_HOME/auth.json) and Oh My Pi
(`omp auth export` / `omp auth import -`, secrets on stdin; omp's credential
tables are emptied first because import merges, and a failed import puts
the outgoing set back). Antigravity is listed as not switchable: agy keeps
its login in the OS keyring.

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

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

- the copy-mode position box is off (copy-mode-position-format "");
- the first keystroke after a mouse report leaves copy-mode
  (`copy-mode -q`, pty-attach) before it is written, once per scroll;
- copy-mode keys that open a tmux prompt (g, :, /, f, digits…) leave
  copy-mode and reach the program;
- the wheel scrolls 10 lines a notch instead of 5, and the first notch
  already scrolls instead of only entering copy-mode.
2026-10-19 11:59:22 +07:00
nntrivi2001 e2d8d314f6 feat(workspace): check the git identity before cloning extra folders
The workspace's "Or clone repositories" box now reads GET /api/me/git and
checks every URL as it is typed: an SSH link while the account has no SSH
key is never sent and points to Settings → Git (make a key, add its public
key to the host); an HTTPS host with no saved login is flagged with an
optional login row, since a public repo needs none. The identity is re-read
when the window regains focus, and a refused SSH login links to the same
settings. The server's ECREDENTIALS / ESSHAUTH answers still decide.
2026-10-19 11:54:49 +07:00
359 changed files with 42016 additions and 7658 deletions
+11 -15
View File
File diff suppressed because one or more lines are too long
+17 -15
View File
File diff suppressed because one or more lines are too long
+128 -86
View File
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
// Asked of the SERVER, not of this shell: a run is a tmux session the server // Asked of the SERVER, not of this shell: a run is a tmux session the server
// spawns, so the only PATH that decides whether Start Run works is its own — // spawns, so the only PATH that decides whether Start Run works is its own —
// a systemd --user service never reads your shell profile. Missing tmux is a // a systemd --user service never reads your shell profile. Missing tmux is a
// warning, not a failure: it costs the run console and `lanes shell`, nothing // warning, not a failure: it costs the run console and `workspace shell`, nothing
// else. // else.
try { try {
const { available, upgrade } = await get("/api/run/tmux"); const { available, upgrade } = await get("/api/run/tmux");
@@ -1693,7 +1693,7 @@ function cmdOpen() {
} }
/** /**
* `agenthub lanes add` — adopt a working directory or provision a managed worktree. * `agenthub workspace add` — adopt a working directory or provision a managed worktree.
*/ */
async function cmdLanesAdd(args) { async function cmdLanesAdd(args) {
const flag = (name) => { const flag = (name) => {
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
if (base) body.base = base; if (base) body.base = base;
if (slug) body.slug = slug; if (slug) body.slug = slug;
if (tool) body.tool = tool; if (tool) body.tool = tool;
const { lane: provisioning } = await post("/api/lanes/worktree", body); const { lane: provisioning } = await post("/api/workspaces/worktree", body);
const deadline = Date.now() + 30_000; const deadline = Date.now() + 30_000;
let lane = provisioning; let lane = provisioning;
while (lane.status === "provisioning" && Date.now() < deadline) { while (lane.status === "provisioning" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250)); await new Promise((resolve) => setTimeout(resolve, 250));
({ lane } = await get(`/api/lanes/${provisioning.id}`)); ({ lane } = await get(`/api/workspaces/${provisioning.id}`));
} }
if (lane.status === "provisioning") { if (lane.status === "provisioning") {
console.error( console.error(
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
const body = { cwd, title }; const body = { cwd, title };
if (pipeline) body.pipeline = pipeline; if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool; if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body); const { lane } = await post("/api/workspaces", body);
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`); console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
} }
/** /**
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes * `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
* (owning session died) and cap oversized hook logs, across every lane on * (owning session died) and cap oversized hook logs, across every lane on
* this machine. Machine-wide, not one lane's; pure local process/file * this machine. Machine-wide, not one lane's; pure local process/file
* action, no HTTP round-trip. * action, no HTTP round-trip.
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
} }
/** /**
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold * `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not * `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
* talk to the dashboard server at all. * talk to the dashboard server at all.
*/ */
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
} }
/** /**
* `agenthub lanes profile check [<path>]` — validate a profile without needing a * `agenthub workspace profile check [<path>]` — validate a profile without needing a
* lane to exist for it yet. Defaults to the current directory, NOT lane-id * lane to exist for it yet. Defaults to the current directory, NOT lane-id
* resolution (unlike every other `lanes` subcommand) — this is meant to run * resolution (unlike every other `workspace` subcommand) — this is meant to run
* against a bare repo right after `profile init`. * against a bare repo right after `profile init`.
*/ */
async function cmdLanesProfileCheck(args) { async function cmdLanesProfileCheck(args) {
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
} }
} }
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */ /** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
async function cmdLanesLifecycle(action, args) { async function cmdLanesLifecycle(action, args) {
const id = args.find((arg) => !arg.startsWith("--")); const id = args.find((arg) => !arg.startsWith("--"));
const fields = LANE_PREFLIGHT_FIELDS[action]; const fields = LANE_PREFLIGHT_FIELDS[action];
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
return; return;
} }
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`); const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields); printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db"); const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) { if (preflight.database) {
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
const body = { confirm: true, expect }; const body = { confirm: true, expect };
if (args.includes("--force")) body.force = true; if (args.includes("--force")) body.force = true;
if (keepDb) body.keepDb = true; if (keepDb) body.keepDb = true;
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true }); const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
if (result.status) { if (result.status) {
const error = result.data?.error || {}; const error = result.data?.error || {};
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`); console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
} }
/** /**
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and * `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
* whether the driving session is still breathing. * whether the driving session is still breathing.
*/ */
/** /**
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
} }
async function cmdLanes() { async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes"); const { lanes, counts } = await get("/api/workspaces");
if (!lanes.length) { if (!lanes.length) {
console.log( console.log(
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>" "no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
* *
* Only the FIRST positional counts as an id, and only when it is all digits. * Only the FIRST positional counts as an id, and only when it is all digits.
* Scanning the whole argv for a number would swallow flag values — * Scanning the whole argv for a number would swallow flag values —
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096. * `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
* *
* @param {string[]} args - Arguments after the subcommand. * @param {string[]} args - Arguments after the subcommand.
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed. * @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined; return i > -1 ? args[i + 1] : undefined;
}; };
const explicit = flagValue("lane") || flagValue("workspace"); const explicit = flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args }; if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) }; if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
const tool = flagValue("tool") || toolFromEnv(); const tool = flagValue("tool") || toolFromEnv();
const cwd = require("path").resolve(flagValue("cwd") || process.cwd()); const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes"); const { lanes } = await get("/api/workspaces");
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked // A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
// against the live lane list AND the directory: a pane outlives the lane it // against the live lane list AND the directory: a pane outlives the lane it
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
} }
/** /**
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as * `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one * opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
* lane id. * lane id.
* *
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
const { laneId, rest: laneArgs } = resolved; const { laneId, rest: laneArgs } = resolved;
if (sub === "runtime") { if (sub === "runtime") {
printRuntime(await get(`/api/lanes/${laneId}/runtime`)); printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
return; return;
} }
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
const body = {}; const body = {};
if (laneArgs.includes("--no-build")) body.build = false; if (laneArgs.includes("--no-build")) body.build = false;
if (laneArgs.includes("--qc")) body.qc = true; if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true }); const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
if (result.status) { if (result.status) {
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`); console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
process.exitCode = 1; process.exitCode = 1;
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
const deadline = Date.now() + 15 * 60 * 1000; const deadline = Date.now() + 15 * 60 * 1000;
for (;;) { for (;;) {
await new Promise((r) => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
const runtime = await get(`/api/lanes/${laneId}/runtime`); const runtime = await get(`/api/workspaces/${laneId}/runtime`);
if (runtime.healthy) { if (runtime.healthy) {
printRuntime(runtime); printRuntime(runtime);
return; return;
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
} }
if (sub === "down") { if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true }); const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
if (result.status) { if (result.status) {
console.error( console.error(
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}` `✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
} }
const i = laneArgs.indexOf("--tail"); const i = laneArgs.indexOf("--tail");
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : ""; const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`); const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) { if (!log.available) {
console.log("no logs for this workspace yet."); console.log("no logs for this workspace yet.");
return; return;
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
return; return;
} }
const result = await post( const result = await post(
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`, `/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
{ args: laneArgs.slice(1) }, { args: laneArgs.slice(1) },
{ allowError: true } { allowError: true }
); );
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
: "merge"; : "merge";
const branch = laneArgs.find((arg) => !arg.startsWith("--")); const branch = laneArgs.find((arg) => !arg.startsWith("--"));
const result = await post( const result = await post(
`/api/lanes/${laneId}/sync-base`, `/api/workspaces/${laneId}/sync-base`,
{ mode, branch }, { mode, branch },
{ allowError: true } { allowError: true }
); );
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
const resolved = await resolveLaneArg(argsAfterName); const resolved = await resolveLaneArg(argsAfterName);
if (!resolved) return null; if (!resolved) return null;
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`; return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
} }
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
async function cmdFeatureList(args) { async function cmdFeatureList(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { features } = await get(`/api/lanes/${resolved.laneId}/features`); const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
if (!features.length) { if (!features.length) {
console.log("no features activated yet — start one with: agenthub feature activate <slug>"); console.log("no features activated yet — start one with: agenthub feature activate <slug>");
return; return;
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
}; };
const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return; if (!resolved) return;
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, { const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
slug, slug,
title: flag("title"), title: flag("title"),
}); });
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
} }
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */ /** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root /** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically * `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
* by anything else in this codebase; a session or hook calls it explicitly. */ * by anything else in this codebase; a session or hook calls it explicitly. */
async function cmdLanesProofLink(args) { async function cmdLanesProofLink(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`); const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
console.log( console.log(
linked linked
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof` ? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
); );
} }
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's /** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium * already-configured MCP servers into <lane>/.mcp.json and seed Chromium
* profiles. Never run automatically; a session calls it explicitly. */ * profiles. Never run automatically; a session calls it explicitly. */
async function cmdLanesMcpSync(args) { async function cmdLanesMcpSync(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true }); const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
if (result.status) { if (result.status) {
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`); console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
process.exitCode = 1; process.exitCode = 1;
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
} }
} }
/** `agenthub lanes integration <name> [<id>]` — check whether a named /** `agenthub workspace integration <name> [<id>]` — check whether a named
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per * integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */ * its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
async function cmdLanesIntegration(args) { async function cmdLanesIntegration(args) {
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== name)); const resolved = await resolveLaneArg(args.filter((a) => a !== name));
if (!resolved) return; if (!resolved) return;
const { enabled } = await get( const { enabled } = await get(
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}` `/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
); );
console.log(enabled ? "on" : "off"); console.log(enabled ? "on" : "off");
process.exitCode = enabled ? 0 : 1; process.exitCode = enabled ? 0 : 1;
} }
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub /** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls; * Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code * the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */ * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
const resolved = await resolveLaneArg(args.slice(2)); const resolved = await resolveLaneArg(args.slice(2));
if (!resolved) return; if (!resolved) return;
const result = await get( const result = await get(
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`, `/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
undefined, undefined,
{ allowError: true } { allowError: true }
); );
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
} }
/** /**
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the * `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
* lane's pipeline instead of the whole thing. * lane's pipeline instead of the whole thing.
* *
* Every node already carries the one command that drives it (`command`, added * Every node already carries the one command that drives it (`command`, added
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
* pasted it into a session by hand. This runs it, in the lane's own cwd: * pasted it into a session by hand. This runs it, in the lane's own cwd:
* *
* - a slash command starts `claude` with it as the prompt; * - a slash command starts `claude` with it as the prompt;
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly. * - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
* *
* With no node id it lists the nodes and their commands, which is also the * With no node id it lists the nodes and their commands, which is also the
* error path's answer. `--print` shows the argv and runs nothing. * error path's answer. `--print` shows the argv and runs nothing.
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
const { flags, positional } = parseArgs(args); const { flags, positional } = parseArgs(args);
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
// The lane payload's `pipeline_nodes` carries render state but not `aliases`; // The lane payload's `pipeline_nodes` carries render state but not `aliases`;
// the template does. Merge, so the listing can still mark the current node. // the template does. Merge, so the listing can still mark the current node.
const { pipelines } = await get("/api/lanes/pipelines"); const { pipelines } = await get("/api/workspaces/pipelines");
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state])); const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({ const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
...n, ...n,
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
} }
/** /**
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline * `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `agenthub lanes add --pipeline` could only set * template a lane renders against. `agenthub workspace add --pipeline` could only set
* this at creation time, so every lane added from the dashboard's "+ Add lane" * this at creation time, so every lane added from the dashboard's "+ Add lane"
* was stuck on `default` with no way back to a 16-node template. * was stuck on `default` with no way back to a 16-node template.
*/ */
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
if (!resolved) return; if (!resolved) return;
if (!target) { if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines"); const { pipelines } = await get("/api/workspaces/pipelines");
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`); console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`); if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`); console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return; return;
} }
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target }); const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
console.log( console.log(
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` + `${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)` `(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
async function cmdLanesShell(args) { async function cmdLanesShell(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const sessionName = `agenthub-lane-${lane.id}`; const sessionName = `agenthub-lane-${lane.id}`;
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], { const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
stdio: "inherit", stdio: "inherit",
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return; if (!resolved) return;
const result = await get( const result = await get(
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`, `/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
undefined, undefined,
{ allowError: true } { allowError: true }
); );
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
if (!resolved) return; if (!resolved) return;
const laneId = resolved.laneId; const laneId = resolved.laneId;
const { lane } = await post(`/api/lanes/${laneId}/stage`, { const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
stage, stage,
status: flag("status"), status: flag("status"),
evidence: flag("evidence"), evidence: flag("evidence"),
@@ -2845,18 +2845,18 @@ const COMMAND_GROUPS = [
], ],
[ [
"server", "server",
"setup|off|status [--hostname name] [--admin name]", "setup|install-system|tools|off|status [--hostname name] [--admin name]",
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)", "Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
], ],
[ [
"users", "users",
"list|add|passwd|role|disable|enable|assign|audit [name]", "list|add|passwd|role|disable|enable|provision|assign|audit [name]",
"Server mode's accounts (add: --role admin|analyst|developer)", "Server mode's accounts (add: --role admin|analyst|developer)",
], ],
[ [
"accounts", "accounts",
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]", "list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one", "Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
], ],
["repl", "", "Open the interactive shell (also: shell, i)"], ["repl", "", "Open the interactive shell (also: shell, i)"],
], ],
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped. ${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1. Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same ${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}. ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands ${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background). require it to be running (${c.bold("agenthub start")} brings one up in the background).
@@ -3512,8 +3512,8 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry /** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */ * whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) { function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES), // `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
// so `help lanes` finds the `workspace …` rows and vice versa. // `help workspaces` finds the `workspace …` rows and vice versa.
const canon = (words) => { const canon = (words) => {
const [head, ...tail] = words.split(" "); const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" "); return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller // prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal. // decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word. /** Command names that are another command under the user-facing word. */
* `workspace` is the name to use; `lanes` (the handler's own name) and const COMMAND_ALIASES = { workspaces: "workspace" };
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
const canonicalArgv = (argv) => const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv; COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over /** The old names `agenthub workspace` and `--lane` were removed in step 4 of
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */ * docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
function warnOldNames(argv) { * — never "Unknown command" or help, so a script written before says why. */
const old = []; function refuseOldNames(argv) {
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`"); if (argv[0] === "lanes") {
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`"); console.error(c.red("✖ renamed: use agenthub workspace …"));
if (old.length) { process.exit(1);
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`)); }
if (argv.includes("--lane")) {
console.error(c.red("✖ renamed: use --workspace <id>"));
process.exit(1);
} }
} }
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]); for (const [name] of items) set.add(name.split(" ")[0]);
} }
set.add("help"); set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical); for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set]; return [...set];
})(); })();
@@ -3812,8 +3810,8 @@ const COMMANDS = (() => {
const SUBCOMMANDS = { const SUBCOMMANDS = {
alerts: ["ack", "ack-all"], alerts: ["ack", "ack-all"],
service: ["install", "uninstall", "status"], service: ["install", "uninstall", "status"],
server: ["setup", "off", "status"], server: ["setup", "install-system", "tools", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"], users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"],
accounts: ["list", "add", "remove", "pin", "unpin"], accounts: ["list", "add", "remove", "pin", "unpin"],
pricing: ["set", "delete", "reset"], pricing: ["set", "delete", "reset"],
webhooks: ["test"], webhooks: ["test"],
@@ -3905,7 +3903,7 @@ async function newPassword(label) {
return first; return first;
} }
/** `agenthub server setup|off|status` — turn server mode on or off. */ /** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */
async function cmdServer(sub, flags) { async function cmdServer(sub, flags) {
const { settingsEnv, auth } = serverAuth(); const { settingsEnv, auth } = serverAuth();
const file = settingsEnv.settingsFilePath(); const file = settingsEnv.settingsFilePath();
@@ -4062,7 +4060,7 @@ async function installSystem(flags, settingsEnv) {
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)"); throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
} }
const which = (bin) => const which = (bin) =>
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim(); spawnSync("sh", ["-c", 'command -v "$1"', "sh", bin], { encoding: "utf8" }).stdout.trim();
const host = os.userInfo().username; const host = os.userInfo().username;
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js")); const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
const data = getDataDir(); const data = getDataDir();
@@ -4166,14 +4164,25 @@ async function cmdUsers(sub, positional, flags) {
case "list": case "list":
table( table(
["id", "username", "role", "state"], ["id", "username", "role", "state"],
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"]) auth
.listUsers()
.map((u) => [
u.id,
u.username,
u.role,
u.disabled ? "disabled" : u.mustChangePassword ? "active (new password due)" : "active",
])
); );
return; return;
case "add": { case "add": {
need(); need();
const role = typeof flags.role === "string" ? flags.role : "developer"; const role = typeof flags.role === "string" ? flags.role : "developer";
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role); const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role, {
console.log(`${c.green("✔")} ${user.username} added (${user.role})`); mustChange: true,
});
console.log(
`${c.green("✔")} ${user.username} added (${user.role}) — they choose their own password at first login`
);
return; return;
} }
case "passwd": case "passwd":
@@ -4317,8 +4326,36 @@ async function cmdCodexAccounts(sub, name, flags) {
); );
} }
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
async function cmdCursorAccounts(sub, name) {
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
if (sub === "add") {
console.log(
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
);
const added = pool.addAccount(name, {
apiKey: await askPassword(`Cursor API key for ${name}: `),
});
console.log(`${c.green("✔")} ${added.name} added`);
console.log(c.dim(" New Cursor sessions now spread over the pool's accounts."));
return;
}
if (sub === "remove") {
pool.removeAccount(name);
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
return;
}
if (sub !== undefined && sub !== "list") {
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool cursor");
}
table(
["cursor account", "live", "pinned"],
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
);
}
/** /**
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] — * `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
* that user's new sessions run on that account (server/lib/account-pins.js). * that user's new sessions run on that account (server/lib/account-pins.js).
*/ */
async function cmdAccountPin(sub, positional, tool) { async function cmdAccountPin(sub, positional, tool) {
@@ -4326,7 +4363,9 @@ async function cmdAccountPin(sub, positional, tool) {
const [first, second] = positional; const [first, second] = positional;
const username = sub === "pin" ? second : first; const username = sub === "pin" ? second : first;
if (!username || (sub === "pin" && !first)) { if (!username || (sub === "pin" && !first)) {
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]"); throw new Error(
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
);
} }
const user = require(path.join(REPO_ROOT, "server", "db.js")) const user = require(path.join(REPO_ROOT, "server", "db.js"))
.db.prepare("SELECT id FROM users WHERE username = ?") .db.prepare("SELECT id FROM users WHERE username = ?")
@@ -4376,14 +4415,15 @@ async function cmdAccounts(sub, positional, flags = {}) {
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js")); const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
const name = positional[0]; const name = positional[0];
if (sub === "add" || sub === "remove") { if (sub === "add" || sub === "remove") {
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`); if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
} }
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") { if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
throw new Error("--tool is claude (the default) or codex"); throw new Error("--tool is claude (the default), codex or cursor");
} }
if (sub === "pin" || sub === "unpin") if (sub === "pin" || sub === "unpin")
return cmdAccountPin(sub, positional, flags.tool || "claude"); return cmdAccountPin(sub, positional, flags.tool || "claude");
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags); if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
if (sub === "add") { if (sub === "add") {
console.log( console.log(
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.") c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
@@ -4439,11 +4479,13 @@ async function cmdAccounts(sub, positional, flags = {}) {
); );
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js")); const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags); if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
return; return;
} }
console.error( console.error(
c.red( c.red(
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]" "✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
) )
); );
process.exitCode = 1; process.exitCode = 1;
@@ -4454,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
* for usage/validation failures. `cmd === "repl"` is handled by the entry * for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */ * points, not here, to avoid recursive REPLs. */
async function runCommand(argv) { async function runCommand(argv) {
warnOldNames(argv); refuseOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv); const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest); const { flags, positional } = parseArgs(rest);
switch (cmd) { switch (cmd) {
@@ -4527,7 +4569,7 @@ async function runCommand(argv) {
return cmdReinstallHooks(); return cmdReinstallHooks();
case "update-check": case "update-check":
return cmdUpdateCheck(); return cmdUpdateCheck();
case "lanes": case "workspace":
if (rest[0] === "add") { if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1)); return cmdLanesAdd(rest.slice(1));
} }
+3 -3
View File
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
The client is a single-page application (SPA) built with modern web technologies: The client is a single-page application (SPA) built with modern web technologies:
- **React 18.3** - Component-based UI with hooks and concurrent features - **React 19** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **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 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`) - **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`)
@@ -1215,7 +1215,7 @@ graph TB
The client is a production-ready React application with: The client is a production-ready React application with:
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind - 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
- ⚡ **Real-time** - WebSocket integration for live updates - ⚡ **Real-time** - WebSocket integration for live updates
- 🧪 **Tested** - Vitest + React Testing Library - 🧪 **Tested** - Vitest + React Testing Library
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading - 📦 **Optimized** - Code splitting, tree shaking, lazy loading
+462 -162
View File
@@ -15,36 +15,36 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0", "@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^6.0.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^26.0.8", "i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0", "lucide-react": "^1.61.0",
"react": "^18.3.1", "react": "^19.3.1",
"react-dom": "^18.3.1", "react-dom": "^19.3.1",
"react-i18next": "^17.0.4", "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.28.2", "react-router-dom": "^7.18.4",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/react": "^18.3.18", "@types/react": "^19.3.0",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^27.0.1", "jsdom": "^27.0.1",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "typescript": "^7.0.2",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
@@ -450,10 +450,9 @@
} }
}, },
"node_modules/@codemirror/merge": { "node_modules/@codemirror/merge": {
"version": "6.12.2", "version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", "integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -478,10 +477,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@codemirror/view": { "node_modules/@codemirror/view": {
"version": "6.43.14", "version": "6.43.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==", "integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.7.0", "@codemirror/state": "^6.7.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1251,15 +1249,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@remix-run/router": {
"version": "1.23.4",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1670,7 +1659,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5" "@babel/runtime": "^7.12.5"
}, },
@@ -2117,30 +2105,21 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.31", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.3.7", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true, "dev": true,
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/unist": { "node_modules/@types/unist": {
@@ -2149,6 +2128,326 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -2291,59 +2590,38 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xterm/addon-canvas": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-clipboard": { "node_modules/@xterm/addon-clipboard": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==", "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
"license": "MIT",
"dependencies": { "dependencies": {
"js-base64": "^3.7.5" "js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^5.4.0"
} }
}, },
"node_modules/@xterm/addon-fit": { "node_modules/@xterm/addon-fit": {
"version": "0.10.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
}, },
"node_modules/@xterm/addon-search": { "node_modules/@xterm/addon-search": {
"version": "0.15.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
}, },
"node_modules/@xterm/addon-web-links": { "node_modules/@xterm/addon-web-links": {
"version": "0.11.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
"license": "MIT", },
"peerDependencies": { "node_modules/@xterm/addon-webgl": {
"@xterm/xterm": "^5.0.0" "version": "0.19.0",
} "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
}, },
"node_modules/@xterm/xterm": { "node_modules/@xterm/xterm": {
"version": "5.5.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
"license": "MIT"
}, },
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
@@ -2740,6 +3018,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/crelt": { "node_modules/crelt": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
@@ -3703,9 +3993,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.4.2", "version": "26.5.11",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", "integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -3720,7 +4010,6 @@
"url": "https://www.locize.com" "url": "https://www.locize.com"
} }
], ],
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"typescript": "^5 || ^6 || ^7" "typescript": "^5 || ^6 || ^7"
}, },
@@ -3731,12 +4020,11 @@
} }
}, },
"node_modules/i18next-browser-languagedetector": { "node_modules/i18next-browser-languagedetector": {
"version": "8.2.1", "version": "8.2.3",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", "integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.2" "@babel/runtime": "^7.29.2"
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
@@ -3921,6 +4209,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
@@ -4019,18 +4308,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -4049,12 +4326,17 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.474.0", "version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==", "integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
"license": "ISC",
"peerDependencies": { "peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
} }
}, },
"node_modules/lz-string": { "node_modules/lz-string": {
@@ -5375,35 +5657,28 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "19.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "19.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "scheduler": "^0.28.0"
"scheduler": "^0.23.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "react": "^19.3.1"
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.16", "version": "17.1.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==", "integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.7", "@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1", "html-parse-stringify": "^4.0.1",
@@ -5472,35 +5747,39 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.30.6", "version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.4" "cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=20.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16.8" "react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "6.30.6", "version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.4", "react-router": "7.18.4"
"react-router": "6.30.6"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=20.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16.8", "react": ">=18",
"react-dom": ">=16.8" "react-dom": ">=18"
} }
}, },
"node_modules/read-cache": { "node_modules/read-cache": {
@@ -5748,13 +6027,9 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
@@ -5766,6 +6041,11 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -6171,17 +6451,37 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc"
"tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=14.17" "node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
} }
}, },
"node_modules/unified": { "node_modules/unified": {
+14 -14
View File
@@ -18,36 +18,36 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0", "@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^6.0.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^26.0.8", "i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0", "lucide-react": "^1.61.0",
"react": "^18.3.1", "react": "^19.3.1",
"react-dom": "^18.3.1", "react-dom": "^19.3.1",
"react-i18next": "^17.0.4", "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.28.2", "react-router-dom": "^7.18.4",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/react": "^18.3.18", "@types/react": "^19.3.0",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^27.0.1", "jsdom": "^27.0.1",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "typescript": "^7.0.2",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 465 KiB

+1 -1
View File
@@ -49,7 +49,7 @@
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g> <g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text> <text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text> <text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text> <text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
</g> </g>
<g transform="translate(792,300)"> <g transform="translate(792,300)">
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/> <rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+64
View File
@@ -0,0 +1,64 @@
/**
* @file AuthCard.tsx
* @description The card the signed-out screens share (the login page, the
* first-login password change in `FirstPasswordPage.tsx`): a centred form
* with the theme's accent glow and grid behind the brand logo, a title and a
* subtitle. While it shows, the OS's light/dark applies (`applySystemTheme`),
* since no account's theme has been chosen for it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useLayoutEffect, type FormEvent, type ReactNode } from "react";
import { BRAND_LOGO } from "../lib/brand";
import { applySystemTheme } from "../hooks/useTheme";
export function AuthCard({
title,
subtitle,
onSubmit,
children,
}: {
title: string;
subtitle: string;
onSubmit: (e: FormEvent) => void;
children: ReactNode;
}) {
// Follow the OS, live.
useLayoutEffect(() => {
applySystemTheme();
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
mq?.addEventListener?.("change", applySystemTheme);
return () => mq?.removeEventListener?.("change", applySystemTheme);
}, []);
return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form
onSubmit={onSubmit}
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
>
{/* Accent glow and a grid fading out from the top, drawn from the
theme's own accent so it holds in every theme. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-56"
style={{
backgroundImage:
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
backgroundPosition: "center top",
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
}}
/>
<div className="relative space-y-5">
<div className="text-center">
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">{title}</h1>
<p className="mt-2 text-sm text-fg-secondary">{subtitle}</p>
</div>
{children}
</div>
</form>
</main>
);
}
+65 -87
View File
@@ -11,7 +11,9 @@
* `usePermissions()` says what that user's role may do, so pages hide the rest. * `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 * Once an account is known (local mode is one), its own UI preferences are
* loaded into this browser (`lib/userPrefs.ts`). * loaded into this browser (`lib/userPrefs.ts`).
* The login page follows the OS's light/dark (`applySystemTheme`). * The login page follows the OS's light/dark (`AuthCard`). An account whose
* password an admin gave (`mustChangePassword`) sees only
* `FirstPasswordPage` until it picks its own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -20,15 +22,14 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useLayoutEffect,
useState, useState,
type FormEvent, type FormEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { BRAND_LOGO } from "../lib/brand"; import { AuthCard } from "./AuthCard";
import { applySystemTheme } from "../hooks/useTheme"; import { FirstPasswordPage } from "./FirstPasswordPage";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api"; import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import { hydrate } from "../lib/userPrefs"; import { hydrate } from "../lib/userPrefs";
@@ -165,6 +166,17 @@ export function AuthGate({ children }: { children: ReactNode }) {
await api.auth.logout().catch(() => {}); await api.auth.logout().catch(() => {});
setState({ mode: "server", user: null }); setState({ mode: "server", user: null });
}; };
// A password an admin gave: nothing else until the user picks their own
// (the server refuses the rest anyway — EPWCHANGE).
if (state.mode === "server" && state.user?.mustChangePassword) {
return (
<FirstPasswordPage
user={state.user}
onDone={() => updateUser({ mustChangePassword: false })}
onLogout={() => void logout()}
/>
);
}
return ( return (
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider> <AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
); );
@@ -178,14 +190,6 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Nobody is signed in, so no account's theme applies: follow the OS, live.
useLayoutEffect(() => {
applySystemTheme();
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
mq?.addEventListener?.("change", applySystemTheme);
return () => mq?.removeEventListener?.("change", applySystemTheme);
}, []);
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
@@ -201,86 +205,60 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
} }
}; };
// Nobody is signed in, so no account's theme applies: AuthCard follows the OS.
return ( return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4"> <AuthCard title={t("auth.title")} subtitle={t("auth.subtitle")} onSubmit={submit}>
<form <label className="block space-y-1.5 text-sm font-medium text-fg-primary">
onSubmit={submit} <span>{t("auth.username")}</span>
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10" <input
> className="input h-11 w-full px-4"
{/* Accent glow and a grid fading out from the top, drawn from the autoComplete="username"
theme's own accent so it holds in every theme. */} autoFocus
<div required
aria-hidden placeholder={t("auth.username")}
className="pointer-events-none absolute inset-x-0 top-0 h-56" value={username}
style={{ onChange={(e) => setUsername(e.target.value)}
backgroundImage:
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
backgroundPosition: "center top",
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
}}
/> />
<div className="relative space-y-5"> </label>
<div className="text-center"> <div className="space-y-1.5 text-sm font-medium text-fg-primary">
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" /> <label htmlFor="login-password">{t("auth.password")}</label>
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary"> <div className="relative">
{t("auth.title")} <input
</h1> id="login-password"
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p> className="input h-11 w-full pl-4 pr-11"
</div> type={showPassword ? "text" : "password"}
<label className="block space-y-1.5 text-sm font-medium text-fg-primary"> autoComplete="current-password"
<span>{t("auth.username")}</span> required
<input placeholder="••••••••"
className="input h-11 w-full px-4" value={password}
autoComplete="username" onChange={(e) => setPassword(e.target.value)}
autoFocus />
required
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
<label htmlFor="login-password">{t("auth.password")}</label>
<div className="relative">
<input
id="login-password"
className="input h-11 w-full pl-4 pr-11"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{/* Inverted fill (text colour behind the surface colour): black in a
light theme, light in a dark one, like the reference design. */}
<button <button
type="submit" type="button"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50" onClick={() => setShowPassword((v) => !v)}
disabled={busy} aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
> >
{busy ? t("auth.submitting") : t("auth.submit")} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
</div> </div>
</form> </div>
</main> {error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{/* Inverted fill (text colour behind the surface colour): black in a
light theme, light in a dark one, like the reference design. */}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={busy}
>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
</AuthCard>
); );
} }
+4
View File
@@ -165,12 +165,15 @@ export function MenuItem({
danger, danger,
disabled, disabled,
testId, testId,
title,
children, children,
}: { }: {
onSelect: () => void; onSelect: () => void;
danger?: boolean; danger?: boolean;
disabled?: boolean; disabled?: boolean;
testId?: string; testId?: string;
/** Hover text — e.g. why a disabled item cannot run. */
title?: string;
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
@@ -179,6 +182,7 @@ export function MenuItem({
role="menuitem" role="menuitem"
data-testid={testId} data-testid={testId}
disabled={disabled} disabled={disabled}
title={title}
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`} className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
onClick={onSelect} onClick={onSelect}
> >
+8
View File
@@ -24,10 +24,18 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`; title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) { } else if (path.startsWith("/sessions")) {
title = t("sessions"); title = t("sessions");
} else if (path.startsWith("/snapshot/")) {
title = t("snapshot");
} else if (path.startsWith("/workspace")) { } else if (path.startsWith("/workspace")) {
title = t("workspace"); title = t("workspace");
} else if (path.startsWith("/settings")) { } else if (path.startsWith("/settings")) {
title = t("settings"); title = t("settings");
} else if (path.startsWith("/artifacts")) {
title = t("artifacts");
} else if (path.startsWith("/files")) {
title = t("files");
} else if (path.startsWith("/admin")) {
title = t("admin");
} else if (path !== "/") { } else if (path !== "/") {
title = t("notFound"); title = t("notFound");
} }
+110
View File
@@ -0,0 +1,110 @@
/**
* @file FirstPasswordPage.tsx
* @description The screen an account signed in with a password an admin GAVE
* (created or reset on the Users page — `mustChangePassword`) sees instead of
* the app: the current password, a new one that must pass every rule (the
* `PasswordRules` checklist ticks them as they are typed) and the same again,
* sent to `POST /api/me/password`. On success the app opens; "Sign out"
* leaves. The server refuses every other request meanwhile (EPWCHANGE).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, type AuthUser } from "../lib/api";
import { passwordProblems } from "../lib/passwordRules";
import { AuthCard } from "./AuthCard";
import { PasswordRules } from "./users/PasswordRules";
export function FirstPasswordPage({
user,
onDone,
onLogout,
}: {
user: AuthUser;
onDone: () => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [again, setAgain] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const mismatch = again.length > 0 && again !== next;
const strong = passwordProblems(next, user.username).length === 0;
const ready = current.length > 0 && strong && again === next && !busy;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.me.changePassword(current, next);
onDone();
} catch (err) {
const code = (err as { code?: string }).code;
setError(
code && t(`users:errors.${code}`, { defaultValue: "" })
? t(`users:errors.${code}`)
: (err as Error).message
);
} finally {
setBusy(false);
}
};
const field = (
label: string,
value: string,
set: (v: string) => void,
autoComplete: string,
autoFocus = false
) => (
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
<span>{label}</span>
<input
type="password"
className="input h-11 w-full px-4"
autoComplete={autoComplete}
autoFocus={autoFocus}
required
value={value}
onChange={(e) => set(e.target.value)}
/>
</label>
);
return (
<AuthCard
title={t("auth.mustChangeTitle")}
subtitle={t("auth.mustChangeSubtitle")}
onSubmit={submit}
>
{field(t("auth.currentPassword"), current, setCurrent, "current-password", true)}
{field(t("auth.newPassword"), next, setNext, "new-password")}
<PasswordRules password={next} username={user.username} />
{field(t("auth.confirmPassword"), again, setAgain, "new-password")}
{(mismatch || error) && (
<p role="alert" className="text-sm text-status-danger">
{error ?? t("auth.mismatch")}
</p>
)}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!ready}
>
{busy ? t("auth.saving") : t("auth.mustChangeSubmit")}
</button>
<button
type="button"
className="block w-full text-center text-sm text-fg-secondary hover:text-fg-primary"
onClick={onLogout}
>
{t("auth.logout")}
</button>
</AuthCard>
);
}
+10 -8
View File
@@ -4,12 +4,14 @@
* Escape closes it. Rendered into `document.body`, so no pane, its stacking * Escape closes it. Rendered into `document.body`, so no pane, its stacking
* layer or a header drawn over a pane can cover it — opened from inside a * layer or a header drawn over a pane can cover it — opened from inside a
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's * pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
* layer, under anything stacked above it. * layer, under anything stacked above it. `useDialog` takes the focus while it
* is open and gives it back, and its Escape closes only this, the top dialog.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect } from "react"; import { useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useDialog } from "../hooks/useDialog";
export function ImageLightbox({ export function ImageLightbox({
src, src,
@@ -20,18 +22,18 @@ export function ImageLightbox({
alt: string; alt: string;
onClose: () => void; onClose: () => void;
}) { }) {
useEffect(() => { const panel = useRef<HTMLDivElement>(null);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); useDialog(true, panel, onClose);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal( return createPortal(
<div <div
ref={panel}
tabIndex={-1}
role="dialog" role="dialog"
aria-modal="true"
aria-label={alt} aria-label={alt}
data-testid="image-lightbox" data-testid="image-lightbox"
onClick={onClose} onClick={onClose}
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8" className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
> >
<img <img
src={src} src={src}
+4 -7
View File
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import type { WSMessage, ImportProgressMessage } from "../lib/types"; import type { WSMessage, ImportProgressMessage } from "../lib/types";
import { PathPickButton } from "./PathPickButton"; import { PathPickButton } from "./PathPickButton";
import { copyText } from "../lib/clipboard";
type Mode = "rescan" | "path" | "upload" | "backup"; type Mode = "rescan" | "path" | "upload" | "backup";
@@ -247,13 +248,9 @@ export function ImportHistory() {
const copyArchiveCmd = async () => { const copyArchiveCmd = async () => {
if (!guide) return; if (!guide) return;
try { if (!(await copyText(guide.archive_command, true))) return;
await navigator.clipboard.writeText(guide.archive_command); setCopied(true);
setCopied(true); setTimeout(() => setCopied(false), 1500);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}; };
const progressText = (() => { const progressText = (() => {
+2 -1
View File
@@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope"; import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope"; import type { ScopeMode } from "../lib/dataScope";
import { PathPickButton } from "./PathPickButton"; import { PathPickButton } from "./PathPickButton";
import { copyText } from "../lib/clipboard";
/** Data-scope choices in display order (the radio group's arrow-key order). */ /** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"]; const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
@@ -863,7 +864,7 @@ export function RemoteSources() {
: t("remoteSources.enable", "Enable")} : t("remoteSources.enable", "Enable")}
</MenuItem> </MenuItem>
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem> <MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}> <MenuItem onSelect={pick((s) => void copyText(s.host))}>
{t("rowMenu.copyHost")} {t("rowMenu.copyHost")}
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
+5 -1
View File
@@ -34,6 +34,7 @@ import {
chooseRunOption, chooseRunOption,
interruptRun, interruptRun,
readRunMenu, readRunMenu,
stepRunMenu,
submitToRun, submitToRun,
type PaneMenu, type PaneMenu,
} from "../../lib/panePaste"; } from "../../lib/panePaste";
@@ -318,7 +319,7 @@ export function PetChat({
const r = await startInFreeTabDetailed(lane.id, { const r = await startInFreeTabDetailed(lane.id, {
prompt, prompt,
...(tool !== lane.tool ? { tool } : {}), ...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}), ...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
}); });
remember({ laneId: r.laneId, runId: r.runId, tool }); remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) { } catch (err) {
@@ -505,6 +506,9 @@ export function PetChat({
if (answerRunMenu(chat.runId, menu, text)) setMenu(null); if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent")); else setError(t("sagi.chat.notSent"));
}} }}
onStep={(dir) => {
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
}}
onCancel={() => { onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null); if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent")); else setError(t("sagi.chat.notSent"));
+1 -1
View File
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
moment = null, moment = null,
}: SagiAvatarProps) { }: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null); const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>(); const rafRef = useRef<number | undefined>(undefined);
// Pupils follow the cursor whenever motion is allowed and the eyes are open. // Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye // (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
+2 -1
View File
@@ -24,6 +24,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain"; import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs"; import { PET_NAME_MAX } from "./prefs";
import { isSubmitEnter } from "../../lib/keys";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */ /** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4; const ATTENTION_SHOWN = 4;
@@ -70,7 +71,7 @@ export function PetNameButton({
onChange={(e) => setRenaming(e.target.value)} onChange={(e) => setRenaming(e.target.value)}
onBlur={save} onBlur={save}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (isSubmitEnter(e)) {
e.preventDefault(); e.preventDefault();
save(); save();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
+3 -3
View File
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
const [bubble, setBubbleState] = useState<SagiBubble | null>(null); const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
const [moment, setMoment] = useState<SagiBrain["moment"]>(null); const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null); const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
const reactionTimer = useRef<ReturnType<typeof setTimeout>>(); const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted()); const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(); const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const momentTimer = useRef<ReturnType<typeof setTimeout>>(); const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const lastBubbleAt = useRef(0); const lastBubbleAt = useRef(0);
const burst = useRef<Burst | null>(null); const burst = useRef<Burst | null>(null);
const momentKey = useRef(0); const momentKey = useRef(0);
+2 -1
View File
@@ -14,6 +14,7 @@ import { api } from "../lib/api";
import { ConfirmModal } from "./ConfirmModal"; import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox"; import { Checkbox } from "./Checkbox";
import { CopyButton } from "./event-views/primitives"; import { CopyButton } from "./event-views/primitives";
import { copyText } from "../lib/clipboard";
export function snapshotUrl(token: string): string { export function snapshotUrl(token: string): string {
return `${window.location.origin}/snapshot/${token}`; return `${window.location.origin}/snapshot/${token}`;
@@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) {
try { try {
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact }); const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
setToken(snapshot.token); setToken(snapshot.token);
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined); void copyText(snapshotUrl(snapshot.token));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
+4 -6
View File
@@ -5,7 +5,8 @@
* — with a green dot while a newer release is out —, how many SESSIONS are * — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and * running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session * `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the * messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the * Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for * sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll, * the URL's `session`, or the focused pane's session from the page's run poll,
@@ -50,9 +51,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
codex: "Codex", codex: "Codex",
antigravity: "Antigravity", antigravity: "Antigravity",
cursor: "Cursor", cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
}; };
function localDate(d = new Date()): string { function localDate(d = new Date()): string {
@@ -281,7 +279,7 @@ function QuotaChips() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the // Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it. // panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>(); const closeTimer = useRef<number | undefined>(undefined);
const show = () => { const show = () => {
window.clearTimeout(closeTimer.current); window.clearTimeout(closeTimer.current);
setOpen(true); setOpen(true);
@@ -406,7 +404,7 @@ export function StatusBar({
const gen = costGen.current; const gen = costGen.current;
const live = () => gen === costGen.current; const live = () => gen === costGen.current;
api.pricing api.pricing
.totalCost({ date: localDate() }) .totalCost({ date: localDate(), mine: true })
.then((r) => live() && setToday(r)) .then((r) => live() && setToday(r))
.catch(() => undefined); .catch(() => undefined);
api.budgets api.budgets
+96 -12
View File
@@ -17,8 +17,15 @@
* keeps answering while the chain installs. New skills/slash commands still * keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a * need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it. Escape, the backdrop and × close it for * 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 * this page load only — also while an update applies: it keeps running in the
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the * background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error. While hidden — or once the status
* stops offering the update mid-run — a small chip in the corner keeps showing
* it, with the plugin chain's current step from `plugin_update_progress`, and a
* click on it shows the modal again. A chain the server reports as running
* (started before a reload or from another tab) is adopted the same way;
* "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder * 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 * 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…) * agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
@@ -182,6 +189,8 @@ export function UpdateNotifier() {
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false); const [fixing, setFixing] = useState(false);
/** The running plugin chain's step, from the polls while it runs. */
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => { const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
@@ -236,20 +245,23 @@ export function UpdateNotifier() {
status?.latest_release || status?.latest_release ||
status?.remote_sha || status?.remote_sha ||
(installed ? `installed:${installed.root}` : null); (installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it. // Updating replaces the host's install: only an admin is offered it. A
// running update stays shown (unless closed) even once the status stops
// offering it — the new version may already be on disk before it restarts.
const show = Boolean( const show = Boolean(
isAdmin && isAdmin &&
status?.update_available &&
dismissKey && dismissKey &&
dismissedSha !== dismissKey && closedKey !== dismissKey &&
closedKey !== dismissKey (applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
); );
const busy = applying || restarting || fixing; const busy = applying || restarting || fixing;
// Closing while an update applies only hides it: the apply request and the
// poll that reloads the page keep running, and a failure re-opens it.
const closeForNow = useCallback(() => { const closeForNow = useCallback(() => {
if (busy || !dismissKey) return; if (!dismissKey) return;
setClosedKey(dismissKey); setClosedKey(dismissKey);
}, [busy, dismissKey]); }, [dismissKey]);
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
if (busy || !dismissKey) return; if (busy || !dismissKey) return;
@@ -261,8 +273,8 @@ export function UpdateNotifier() {
setDismissedSha(dismissKey); setDismissedSha(dismissKey);
}, [busy, dismissKey]); }, [busy, dismissKey]);
// Escape closes for now — never while an update is applying. // Escape closes for now — an applying update carries on in the background.
useDialog(show, panelRef, busy ? undefined : closeForNow); useDialog(show, panelRef, closeForNow);
const copyCmd = async () => { const copyCmd = async () => {
if (!status?.manual_command) return; if (!status?.manual_command) return;
@@ -307,6 +319,7 @@ export function UpdateNotifier() {
api.updates api.updates
.status() .status()
.then((s) => { .then((s) => {
setProgress(s.plugin_update_progress ?? null);
const v = s.last_plugin_update; const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since; const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it // A failed step, or nothing to pull, leaves the OLD server up: it
@@ -315,6 +328,7 @@ export function UpdateNotifier() {
syncFromPayload(s); syncFromPayload(s);
setRestarting(false); setRestarting(false);
if (v.ok) toast.info(t("pluginAlreadyCurrent")); if (v.ok) toast.info(t("pluginAlreadyCurrent"));
else setClosedKey(null); // closed while it ran: show why it failed
return; return;
} }
// A new version answering is back too, even if a poll never // A new version answering is back too, even if a poll never
@@ -333,9 +347,23 @@ export function UpdateNotifier() {
[syncFromPayload, t] [syncFromPayload, t]
); );
// A chain already running on the server (started before a reload, in another
// tab, or from the old bundle) is adopted: this tab shows its steps, then
// reloads onto the new server like the tab that started it.
const running = status?.plugin_update_progress ?? null;
const adoptedRef = useRef(false);
useEffect(() => {
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
adoptedRef.current = true;
setProgress(running);
setRestarting(true);
pollUntilBack(true, status?.local_version ?? null);
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
const applyNow = async () => { const applyNow = async () => {
if (applying || restarting) return; if (applying || restarting) return;
setError(null); setError(null);
setProgress(null);
setApplying(true); setApplying(true);
try { try {
const result = await api.updates.apply(); const result = await api.updates.apply();
@@ -359,6 +387,8 @@ export function UpdateNotifier() {
} finally { } finally {
setApplying(false); setApplying(false);
} }
// Not applied: re-open a modal closed while it ran, so the error is seen.
setClosedKey(null);
}; };
// Hand the failure to an AI agent: a workspace on the install folder (the // Hand the failure to an AI agent: a workspace on the install folder (the
@@ -384,6 +414,54 @@ export function UpdateNotifier() {
} }
}; };
const stepText = progress?.step
? t(`pluginStep.${progress.step}`, {
index: progress.index,
total: progress.total,
defaultValue: progress.step,
})
: null;
// Hidden while it runs — closed, or the status no longer offers the update
// (the new version is already on disk): a chip keeps it in sight.
if (!show && isAdmin && (applying || restarting)) {
return (
<button
type="button"
data-testid="update-chip"
onClick={() => setClosedKey(null)}
title={t("chipHint")}
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
<span className="truncate">
{status?.latest_release
? t("chipTitle", { version: status.latest_release })
: t("updating")}
</span>
</span>
<span className="mt-1 block truncate text-[11px] text-fg-muted">
{stepText || (applying ? t("updating") : t("restartingShort"))}
</span>
{progress && progress.total > 0 ? (
<span
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={progress.index}
aria-valuemin={0}
aria-valuemax={progress.total}
>
<span
className="block h-full bg-accent-solid transition-all"
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
/>
</span>
) : null}
</button>
);
}
if (!show || !status) return null; if (!show || !status) return null;
const refLabel = status.remote_ref || "origin"; const refLabel = status.remote_ref || "origin";
@@ -436,7 +514,6 @@ export function UpdateNotifier() {
<button <button
type="button" type="button"
onClick={closeForNow} onClick={closeForNow}
disabled={busy}
aria-label={t("later")} aria-label={t("later")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0" className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
> >
@@ -513,7 +590,14 @@ export function UpdateNotifier() {
{/* The plugin chain pulls a new cache directory and reinstalls {/* The plugin chain pulls a new cache directory and reinstalls
dependencies before it restarts, so it takes minutes, not dependencies before it restarts, so it takes minutes, not
seconds — saying "restarting" there would look hung. */} seconds — saying "restarting" there would look hung. */}
{pluginManaged ? t("pluginUpdating") : t("restarting")} <span>
{pluginManaged ? t("pluginUpdating") : t("restarting")}
{stepText ? (
<span className="block mt-0.5 font-medium" data-testid="update-step">
{stepText}
</span>
) : null}
</span>
</div> </div>
) : null} ) : null}
+415 -10
View File
@@ -15,6 +15,16 @@
* first, no owner last, groups foldable — `collapsedOwners`); one this user * first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a * may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane. * session opens its analysis page (`/sessions/<id>`), never a pane.
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
* server like the other preferences) arrange the list under foldable headers —
* each group in its order, then "Ungrouped" — inside each owner group when
* there are owner groups. A workspace joins a group from its menu, by a drop
* on the group's header, by a drag that lands it below a row of that
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
* another to reorder the groups, and its menu renames, moves, folds every
* group, adds a new workspace straight into it or deletes it (its
* workspaces stay, ungrouped). A folded header still shows the worst state
* of its workspaces (needs you, error, running) and marks the selected one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -30,7 +40,7 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react"; import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api"; import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types"; import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes"; import { useLanes } from "../hooks/useLanes";
@@ -46,7 +56,31 @@ import {
writeLaneOrder, writeLaneOrder,
} from "../lib/laneOrderStorage"; } from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs"; import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow"; import {
arrangeByGroup,
assignGroup,
createGroup,
deleteGroup,
groupOf,
moveGroup,
moveGroupBefore,
pruneWorkspaceGroups,
renameGroup,
setAllFolded,
setGroupFolded,
useWorkspaceGroups,
type WorkspaceGroup,
} from "../lib/workspaceGroups";
import { confirmDialog } from "../lib/confirm";
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
import {
LANE_DRAG_TYPE,
WorkspaceRow,
isLaneDrag,
workspaceDot,
workspaceDotLabel,
workspaceName,
} from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList"; import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { UserAvatar } from "./users/UserAvatar"; import { UserAvatar } from "./users/UserAvatar";
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
); );
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null); const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
// "New workspace in this group": the group the added workspace joins.
const [addToGroup, setAddToGroup] = useState<string | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]); const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [orderTick, setOrderTick] = useState(0); const [orderTick, setOrderTick] = useState(0);
const groups = useWorkspaceGroups();
const hasGroups = groups.groups.length > 0;
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
const [groupMenu, setGroupMenu] = useState<{
group: WorkspaceGroup;
x: number;
y: number;
} | null>(null);
// A removed workspace leaves the stored groups (they hold ids only).
const laneIdsKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
}, [laneIdsKey]);
// A slot-0 pane starts, ends or learns its session id exactly when a lane's // A slot-0 pane starts, ends or learns its session id exactly when a lane's
// status or session moves, so that is when the live-pane list is re-read; // status or session moves, so that is when the live-pane list is re-read;
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
return () => window.removeEventListener(LIVE_RUNS_EVENT, take); return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []); }, []);
const openAdd = (sourceRepo?: string) => { const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
setAddPrefill(sourceRepo ? { sourceRepo } : null); setAddPrefill(sourceRepo ? { sourceRepo } : null);
setAddToGroup(groupId);
setAddOpen(true); setAddOpen(true);
api.run api.run
.cwds() .cwds()
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
const toolFilter = const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null; tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(() => { const sorted = useMemo(() => {
const list = sortByStoredOrder(lanes, order).filter( const list = arrangeByGroup(
(l) => !toolFilter || l.tool === toolFilter sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
groups
); );
if (!grouped) return list; if (!grouped) return list;
// Stable: each owner's workspaces keep the stored order inside the group. // Stable: each owner's workspaces keep the stored order inside the group.
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
return [...list].sort( return [...list].sort(
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b)) (a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
); );
}, [lanes, order, toolFilter, grouped, meId]); }, [lanes, order, toolFilter, grouped, meId, groups]);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const nameHit = (l: Lane) => const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q); workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
setOrderTick((n) => n + 1); setOrderTick((n) => n + 1);
}; };
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
// screen and joins the next group instead (the arrangement would otherwise
// put it straight back): last of the group above, first of the one below —
// opened if folded, so the focused row stays shown. The shown order is
// written as it stands, so the store agrees. Never across an owner's section.
const nudge = (from: number, to: number) => {
const lane = sorted[from];
const next = sorted[to];
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
const target = groupOf(groups, next.id);
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
assignGroup(lane.id, target?.id ?? null);
if (target?.folded) setGroupFolded(target.id, false);
reorder((ids) => [...ids]);
return;
}
}
reorder((ids) => moveLane(ids, from, to));
};
// A row only starts a drag; the whole section takes the drop, so a gap — a // A row only starts a drag; the whole section takes the drop, so a gap — a
// workspace's session list, the space above or below a row — lands too. // workspace's session list, the space above or below a row — lands too.
// `dropAt` is the insertion point in `sorted`, drawn as a line. // `dropAt` is the insertion point in `sorted`, drawn as a line.
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE); const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row. // Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw); const from = raw === "" ? NaN : Number(raw);
if (!Number.isInteger(from)) return;
const lane = sorted[from];
// Dropped on a group's header: into that group, at its top.
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
if (header && lane) {
const gid = header.dataset.groupDrop || null;
assignGroup(lane.id, gid);
const first = sorted.findIndex(
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
);
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
return;
}
const before = dropIndexAt(e.clientY); const before = dropIndexAt(e.clientY);
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before)); // Elsewhere it joins the group of the row it now sits under (the line
// drawn above a header closes the section before it).
if (lane && hasGroups) {
const rest = sorted.filter((l) => l.id !== lane.id);
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
const neighbour = rest[at - 1] ?? rest[0];
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
}
reorder((ids) => moveBefore(ids, from, before));
}, },
}; };
// A group no workspace is in yet still shows (to be renamed, or dropped on),
// just above the ungrouped ones; a search hides it.
const emptyGroups = q
? []
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
<GroupHeader
key={g?.id ?? "none"}
group={g}
count={members.length}
// What a folded group hides: the most urgent of its workspaces, and
// whether the open one is among them.
worst={worstOf(members)}
holdsSelected={members.some((l) => l.id === selected.lane)}
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
folded={!!g?.folded && !q}
renaming={!!g && renamingGroup === g.id}
first={first}
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
onRenameEnd={(name) => {
setRenamingGroup(null);
if (g && name !== null) renameGroup(g.id, name);
}}
/>
);
const newGroup = (laneIds: number[] = []) => {
const id = createGroup(t("tree.groupDefaultName"), laneIds);
setRenamingGroup(id);
};
const removeGroup = async (g: WorkspaceGroup) => {
const ok = await confirmDialog({
title: t("tree.groupDelete"),
message: t("tree.groupDeleteConfirm", { name: g.name }),
confirmLabel: t("tree.groupDelete"),
});
if (!ok) return;
deleteGroup(g.id);
};
const rename = async (lane: Lane, title: string | null) => { const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null); setRenamingId(null);
if (title === null || title === (lane.title || "")) return; if (title === null || title === (lane.title || "")) return;
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
> >
<Search className="h-3.5 w-3.5" /> <Search className="h-3.5 w-3.5" />
</button> </button>
<button
type="button"
data-testid="workspace-tree-new-group"
onClick={() => newGroup()}
aria-label={t("tree.newGroup")}
title={t("tree.newGroup")}
className={headBtn}
>
<FolderPlus className="h-3.5 w-3.5" />
</button>
<button <button
type="button" type="button"
data-testid="workspace-tree-add" data-testid="workspace-tree-add"
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
: ownerShown(lane) || `#${lane.owner_id}`; : ownerShown(lane) || `#${lane.owner_id}`;
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner); const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l)); const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
// The user's own group, inside the owner's.
const ug = groupOf(groups, lane.id);
const ugKey = ug?.id ?? "";
const sameSection = (l: Lane) =>
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
const prev = sorted[i - 1];
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
const sectionFolded = !!ug?.folded && !q;
const hiddenRow = folded || sectionFolded;
return ( return (
<div key={lane.id} className="relative"> <div key={lane.id} className="relative">
{groupStart && groupShown && ( {groupStart && groupShown && (
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
</span> </span>
</button> </button>
)} )}
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
{sectionStart &&
sectionShown &&
!folded &&
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
{dropAt === i && <DropLine />} {dropAt === i && <DropLine />}
{visible(lane) && !folded && ( {visible(lane) && !hiddenRow && (
<WorkspaceRow <WorkspaceRow
lane={lane} lane={lane}
index={i} index={i}
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
}} }}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))} onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)} onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))} onReorder={nudge}
onMenu={(x, y) => setMenu({ lane, x, y })} onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)} onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)} onRenameEnd={(title) => void rename(lane, title)}
/> />
)} )}
{expanded(lane.id) && !folded && ( {expanded(lane.id) && !hiddenRow && (
<WorkspaceSessionList <WorkspaceSessionList
lane={lane} lane={lane}
liveRuns={liveRuns} liveRuns={liveRuns}
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
</div> </div>
); );
})} })}
{firstUngrouped === -1 &&
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
{dropAt === sorted.length && ( {dropAt === sorted.length && (
<div className="relative"> <div className="relative">
<DropLine /> <DropLine />
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
y={menu.y} y={menu.y}
onClose={() => setMenu(null)} onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)} onAction={(a) => onMenuAction(menu.lane, a)}
groups={groups.groups}
groupId={groups.of[menu.lane.id] ?? null}
onGroup={(target) => {
const id = menu.lane.id;
setMenu(null);
if (target === "new") newGroup([id]);
else assignGroup(id, target);
}}
/> />
)} )}
{groupMenu && (
<ContextMenu
x={groupMenu.x}
y={groupMenu.y}
onClose={() => setGroupMenu(null)}
testId={`workspace-group-menu-${groupMenu.group.id}`}
>
<MenuItem
onSelect={() => {
setRenamingGroup(groupMenu.group.id);
setGroupMenu(null);
}}
>
{t("tree.groupRename")}
</MenuItem>
<MenuItem
disabled={groups.groups[0]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, -1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveUp")}
</MenuItem>
<MenuItem
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, 1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveDown")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="workspace-group-add"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
openAdd(undefined, g.id);
}}
>
{t("tree.groupAddWorkspace")}
</MenuItem>
<MenuItem
testId="workspace-group-fold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(true);
}}
>
{t("tree.groupFoldAll")}
</MenuItem>
<MenuItem
testId="workspace-group-unfold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(false);
}}
>
{t("tree.groupUnfoldAll")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
testId="workspace-group-delete"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
void removeGroup(g);
}}
>
{t("tree.groupDelete")}
</MenuItem>
</ContextMenu>
)}
<Suspense fallback={null}> <Suspense fallback={null}>
{destructive && ( {destructive && (
<DestructiveLaneModal <DestructiveLaneModal
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
onClose={() => { onClose={() => {
setAddOpen(false); setAddOpen(false);
setAddPrefill(null); setAddPrefill(null);
setAddToGroup(null);
}} }}
onAdded={(lane) => { onAdded={(lane) => {
if (addToGroup) assignGroup(lane.id, addToGroup);
void refreshLanes(); void refreshLanes();
select(lane.id); select(lane.id);
}} }}
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
); );
} }
/** What a group header's drag carries: the group's id. */
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
// needsYou outranks an error, an error a running agent; idle/active say nothing.
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
function worstOf(lanes: Lane[]): Lane | null {
let best: Lane | null = null;
let top = 0;
for (const l of lanes) {
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
if (u > top) [best, top] = [l, u];
}
return best;
}
/** A workspace's owner group: its user id, or "none". */ /** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id)); const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/**
* A section's header: a user group (foldable, menu, inline rename) or, with
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
*/
function GroupHeader({
group,
count,
worst,
holdsSelected,
folded,
renaming,
first,
onToggle,
onMenu,
onRenameEnd,
onGroupDrop,
}: {
group: WorkspaceGroup | null;
count: number;
/** The most urgent workspace inside, drawn as its dot while folded. */
worst: Lane | null;
/** The open workspace is in this group (marked while folded). */
holdsSelected: boolean;
folded: boolean;
renaming: boolean;
first: boolean;
onToggle: () => void;
onMenu: (x: number, y: number) => void;
/** The typed name, or null when the edit was cancelled. */
onRenameEnd: (name: string | null) => void;
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
onGroupDrop: (draggedId: string) => void;
}) {
const { t } = useTranslation("lanes");
const [draft, setDraft] = useState(group?.name ?? "");
const [dropOver, setDropOver] = useState(false);
useEffect(() => {
if (renaming) setDraft(group?.name ?? "");
}, [renaming, group?.name]);
const label = group ? group.name : t("tree.ungrouped");
return (
<div
data-group-drop={group?.id ?? ""}
data-testid={`workspace-group-${group?.id ?? "none"}`}
onContextMenu={(e) => {
if (!group) return;
e.preventDefault();
onMenu(e.clientX, e.clientY);
}}
// A group (never "Ungrouped") is dragged by its header to reorder.
draggable={!!group && !renaming}
onDragStart={(e) => {
if (!group) return;
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isGroupDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropOver(true);
}}
onDragLeave={() => setDropOver(false)}
onDrop={(e) => {
setDropOver(false);
if (!isGroupDrag(e)) return;
e.preventDefault();
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
if (id) onGroupDrop(id);
}}
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
>
{renaming && group ? (
<input
autoFocus
value={draft}
maxLength={60}
aria-label={t("tree.groupNameLabel")}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => onRenameEnd(draft)}
onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft);
else if (e.key === "Escape") onRenameEnd(null);
}}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
/>
) : (
<button
type="button"
aria-expanded={group ? !folded : undefined}
disabled={!group}
title={group ? t("tree.groupToggle", { name: label }) : undefined}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
>
{group ? (
folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
>
{label}
</span>
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
{folded && worst && (
<span
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
className="ml-1 flex shrink-0 items-center"
>
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
</span>
)}
</button>
)}
{group && !renaming && (
<button
type="button"
aria-label={t("tree.groupMenu")}
title={t("tree.groupMenu")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
onMenu(r.left, r.bottom);
}}
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
/** Where a dragged workspace would land: a line on its parent's top edge. */ /** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() { function DropLine() {
return ( return (
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
import type { Agent } from "../../lib/types"; import type { Agent } from "../../lib/types";
import { formatModelName, fmtCost } from "../../lib/format"; import { formatModelName, fmtCost } from "../../lib/format";
function renderCard(element: JSX.Element) { function renderCard(element: React.JSX.Element) {
return render(<MemoryRouter>{element}</MemoryRouter>); return render(<MemoryRouter>{element}</MemoryRouter>);
} }
@@ -6,7 +6,9 @@
* `useWorkLanes` mirror the server's roles: an admin changes everything, an * `useWorkLanes` mirror the server's roles: an admin changes everything, an
* analyst reads everything but works only in their own workspaces, a * analyst reads everything but works only in their own workspaces, a
* developer only ever sees (and so changes) their own. The login page wears the * developer only ever sees (and so changes) their own. The login page wears the
* OS's light/dark; a sign-in swaps in the account's saved appearance. * OS's light/dark; a sign-in swaps in the account's saved appearance. A
* password an admin gave shows only the "choose your own password" page,
* whose button waits for every rule, until the change succeeds.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -18,6 +20,7 @@ import "../../i18n";
const me = vi.fn(); const me = vi.fn();
const login = vi.fn(); const login = vi.fn();
const prefs = vi.fn(); const prefs = vi.fn();
const changePassword = vi.fn();
const LANES = [ const LANES = [
{ id: 1, owner_id: 5 }, { id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 }, { id: 2, owner_id: 6 },
@@ -27,7 +30,11 @@ vi.mock("../../lib/api", () => ({
api: { api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() }, auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) }, lanes: { list: () => Promise.resolve({ lanes: LANES }) },
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) }, me: {
prefs: () => prefs(),
savePrefs: () => Promise.resolve({ prefs: {} }),
changePassword: (c: string, n: string) => changePassword(c, n),
},
}, },
})); }));
@@ -56,10 +63,30 @@ beforeEach(() => {
me.mockReset(); me.mockReset();
login.mockReset(); login.mockReset();
prefs.mockReset().mockResolvedValue({ prefs: {} }); prefs.mockReset().mockResolvedValue({ prefs: {} });
changePassword.mockReset().mockResolvedValue({ ok: true });
localStorage.clear(); localStorage.clear();
}); });
describe("AuthGate", () => { describe("AuthGate", () => {
it("makes an account with an admin-given password choose its own first", async () => {
const ue = userEvent.setup();
me.mockResolvedValue({ mode: "server", user: { ...user, mustChangePassword: true } });
render(<AuthGate>app</AuthGate>);
const save = await screen.findByRole("button", { name: "Save and continue" });
expect(screen.queryByText("app")).toBeNull();
await ue.type(screen.getByLabelText("Current password"), "123");
await ue.type(screen.getByLabelText("New password"), "weakpass");
await ue.type(screen.getByLabelText("Confirm new password"), "weakpass");
expect(save).toBeDisabled();
await ue.clear(screen.getByLabelText("New password"));
await ue.clear(screen.getByLabelText("Confirm new password"));
await ue.type(screen.getByLabelText("New password"), "Green-Tea-42");
await ue.type(screen.getByLabelText("Confirm new password"), "Green-Tea-42");
await ue.click(save);
expect(changePassword).toHaveBeenCalledWith("123", "Green-Tea-42");
expect(await screen.findByText("app")).toBeInTheDocument();
});
it("renders the app in local mode", async () => { it("renders the app in local mode", async () => {
me.mockResolvedValue({ mode: "local" }); me.mockResolvedValue({ mode: "local" });
render(<AuthGate>app</AuthGate>); render(<AuthGate>app</AuthGate>);
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /); expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /); expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/settings")).toMatch(/^Settings · /); expect(titleFor("/settings")).toMatch(/^Settings · /);
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
expect(titleFor("/files")).toMatch(/^Files · /);
expect(titleFor("/admin")).toMatch(/^Administration · /);
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
});
it("titles an unknown route as not found", () => {
expect(titleFor("/nope")).toMatch(/^Not found · /);
}); });
}); });
@@ -58,14 +58,25 @@ describe("MarkdownPreview", () => {
describe("FileViewer — markdown preview", () => { describe("FileViewer — markdown preview", () => {
const data = { text: "# Hello\n", truncated: false } as never; const data = { text: "# Hello\n", truncated: false } as never;
it("switches a markdown file between raw and preview", async () => { it("opens a markdown file rendered, and switches it to raw and back", async () => {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />); render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
expect(screen.queryByTestId("markdown-preview")).toBeNull(); expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument(); expect(screen.getByText("# Hello")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Preview" })); await userEvent.click(screen.getByRole("button", { name: "Preview" }));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" })); });
expect(screen.queryByTestId("markdown-preview")).toBeNull();
it("opens it as source when Settings → Appearance says Raw", () => {
window.localStorage.setItem("agenthub.markdown.view", "raw");
try {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument();
} finally {
window.localStorage.removeItem("agenthub.markdown.view");
}
}); });
it("offers no preview for another file type", () => { it("offers no preview for another file type", () => {
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar"; import { StatusBar } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree"; import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: JSX.Element) => const at = (url: string, ui: React.JSX.Element) =>
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>); render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
describe("StatusBar", () => { describe("StatusBar", () => {
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
const conn = screen.getByRole("button", { name: /Live/ }); const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`); expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)"); const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() }); expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
expect(cost).toHaveAttribute( expect(cost).toHaveAttribute(
"title", "title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03" "Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
@@ -238,7 +238,7 @@ describe("StatusBar", () => {
expect(work).toHaveTextContent("5h95%Resets in 41m"); expect(work).toHaveTextContent("5h95%Resets in 41m");
expect(work).toHaveTextContent("Week32%"); expect(work).toHaveTextContent("Week32%");
expect(personal).toHaveTextContent("home@example.com · Sign-in expired"); expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles"); expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles");
expect(personal).not.toHaveTextContent("in use"); expect(personal).not.toHaveTextContent("in use");
expect(codex).toHaveTextContent("dev@example.com"); expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired"); expect(codex).toHaveTextContent("Sign-in expired");
@@ -298,12 +298,102 @@ describe("UpdateNotifier — closing vs skipping", () => {
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument(); expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
}); });
it("ignores Escape while an update is applying", async () => { it("closes while an update applies, and re-opens if it then fails", async () => {
applyMock.mockReturnValue(new Promise(() => {})); let fail: (e: Error) => void = () => {};
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
render(<UpdateNotifier />); render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow"))); await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await userEvent.keyboard("{Escape}"); await userEvent.keyboard("{Escape}");
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument(); await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
fail(new Error("Command failed: npm run build"));
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
});
});
describe("UpdateNotifier — hidden while it runs", () => {
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
statusMock.mockResolvedValue({
...PLUGIN,
plugin_update_progress: {
step: "plugin_update",
index: 2,
total: 3,
started_at: new Date().toISOString(),
},
});
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
).toBeInTheDocument();
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
const running: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
// The new version is already installed: the status no longer offers it.
update_available: false,
plugin_update_progress: {
step: "bootstrap",
index: 3,
total: 3,
started_at: new Date().toISOString(),
},
};
statusMock.mockResolvedValue(running);
render(<UpdateNotifier />);
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
expect(screen.queryByTestId("update-chip")).toBeNull();
}); });
}); });
@@ -6,7 +6,9 @@
* workspace's OWN sessions only, a session's state dot, rename and archive, * workspace's OWN sessions only, a session's state dot, rename and archive,
* selection * selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the * by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, and the status dot. * search, the row menu's items by kind and tool, the status dot, and the
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
* state dot, dragging a header to reorder, fold all).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
}; };
}); });
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
vi.mock("../lanes/AddLaneModal", () => ({
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
<button
type="button"
data-testid="stub-add-lane"
onClick={() => {
onAdded(lane(2, "/r/sub"));
onClose();
}}
/>
),
}));
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { WorkspaceTree } from "../WorkspaceTree"; import { WorkspaceTree } from "../WorkspaceTree";
import { workspaceDot } from "../lanes/WorkspaceRow"; import { workspaceDot } from "../lanes/WorkspaceRow";
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
menu = screen.getByTestId("workspace-row-menu-3"); menu = screen.getByTestId("workspace-row-menu-3");
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument(); expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
}); });
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// No group yet: no headers at all.
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
// A new group from the row menu holds that workspace and asks for a name.
await user.click(screen.getByTestId("workspace-menu-3"));
await user.click(screen.getByTestId("workspace-move-group-new"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Backend{Enter}");
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
const gid = stored.groups[0].id as string;
expect(stored.of).toEqual({ 3: gid });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
// Another workspace joins it from its menu.
await user.click(screen.getByTestId("workspace-menu-2"));
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
);
// Folding hides its rows; the ungrouped one stays.
await user.click(
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
expanded: true,
})
);
expect(rowIds()).toEqual(["workspace-row-1"]);
// Deleting the group only ungroups: every workspace is still listed.
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
await user.click(screen.getByTestId("workspace-group-delete"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
await user.click(screen.getByTestId("workspace-tree-new-group"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Later{Enter}");
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
const header = screen.getByTestId(`workspace-group-${gid}`);
expect(header).toHaveTextContent("Later0");
expect(
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(rowIds()).toHaveLength(3);
});
});
describe("WorkspaceTree groups", () => {
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
const seed = (extra: { folded?: boolean } = {}) =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({
groups: [
{ id: "ga", name: "Alpha", ...extra },
{ id: "gb", name: "Beta" },
],
of: { 1: "ga", 3: "gb" },
})
);
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
seed();
const user = userEvent.setup();
renderTree();
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
// The last of Alpha, Alt+↓: the first of Beta.
screen.getByTestId("workspace-open-1").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
// The last of Beta, Alt+↓: out into the ungrouped ones.
screen.getByTestId("workspace-open-3").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb" });
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a folded group's most urgent state and marks the open workspace", async () => {
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
seed({ folded: true });
renderTree("/workspace?lane=1");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
const state = screen.getByTestId("workspace-group-state-ga");
expect(state).toHaveTextContent("running");
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
"text-accent"
);
// An open group shows its rows instead.
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
});
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
seed();
renderTree();
await screen.findByTestId("workspace-group-gb");
const data = new Map<string, string>();
const dataTransfer = {
get types() {
return [...data.keys()];
},
setData: (k: string, v: string) => data.set(k, v),
getData: (k: string) => data.get(k) ?? "",
dropEffect: "move",
effectAllowed: "move",
};
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
data.clear();
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
// A header drag never moves a workspace.
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
});
it("puts a workspace added from a group's menu into that group", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-gb");
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
// The header's own Add belongs to no group: what it adds stays where it is.
act(() =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({ ...stored(), of: { 1: "ga" } })
)
);
await user.click(screen.getByTestId("workspace-tree-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga" });
});
it("folds and unfolds every group from a header's menu", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-ga");
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
await user.click(screen.getByTestId("workspace-group-fold-all"));
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-unfold-all"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
});
}); });
describe("workspaceDot", () => { describe("workspaceDot", () => {
@@ -14,18 +14,19 @@
* and tick the ones to add; or type `name@marketplace` by hand, which is * 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. * also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB). * - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react"; import { X } from "lucide-react";
import { api, type MarketplaceInfo } from "../../lib/api"; import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast"; 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 field = "input w-full";
const label = "block text-[11px] font-medium text-fg-secondary"; const label = "block text-xs text-fg-secondary";
const hint = "text-[11px] text-fg-muted"; const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */ /** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
); );
} }
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
const clean = text.replace(/^\uFEFF/, "");
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
return {
name: frontmatterField(clean, "name"),
description: frontmatterField(clean, "description"),
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
};
}
/** The name field with the folder/command it becomes. */ /** The name field with the folder/command it becomes. */
function SkillNameField({ export function SkillNameField({
value, value,
onChange, onChange,
disabled, disabled,
@@ -175,7 +187,7 @@ function SkillNameField({
{t("skill.name")} {t("skill.name")}
{required ? " *" : ""} {required ? " *" : ""}
<input <input
className={`${field} mt-0.5`} className={`${field} mt-1`}
value={value} value={value}
disabled={disabled} disabled={disabled}
placeholder="release-notes" placeholder="release-notes"
@@ -296,7 +308,7 @@ export function SkillAddPanel({
<label className={label}> <label className={label}>
{t("skill.description")} {t("skill.description")}
<textarea <textarea
className={`${field} mt-0.5`} className={`${field} mt-1`}
rows={2} rows={2}
value={description} value={description}
disabled={many} disabled={many}
@@ -310,7 +322,7 @@ export function SkillAddPanel({
<label className={label}> <label className={label}>
{t("form.title")} {t("form.title")}
<input <input
className={`${field} mt-0.5`} className={`${field} mt-1`}
value={title} value={title}
disabled={many} disabled={many}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
@@ -362,7 +374,6 @@ export function SkillAddPanel({
dragging ? "border-accent bg-accent/10" : "border-border" 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> <p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
<div className="mt-2 flex flex-wrap justify-center gap-2"> <div className="mt-2 flex flex-wrap justify-center gap-2">
<button <button
@@ -370,14 +381,14 @@ export function SkillAddPanel({
className="btn-ghost text-xs" className="btn-ghost text-xs"
onClick={() => fileInput.current?.click()} onClick={() => fileInput.current?.click()}
> >
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")} {t("skill.pickFiles")}
</button> </button>
<button <button
type="button" type="button"
className="btn-ghost text-xs" className="btn-ghost text-xs"
onClick={() => folderInput.current?.click()} onClick={() => folderInput.current?.click()}
> >
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")} {t("skill.pickFolder")}
</button> </button>
</div> </div>
<input <input
@@ -452,7 +463,10 @@ export function SkillAddPanel({
/> />
)} )}
{meta} {meta}
<div className="flex justify-end"> <div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button <button
type="submit" type="submit"
className="btn-primary text-sm" className="btn-primary text-sm"
@@ -489,7 +503,7 @@ export function SkillAddPanel({
<label className={label}> <label className={label}>
{t("skill.description")} * {t("skill.description")} *
<textarea <textarea
className={`${field} mt-0.5`} className={`${field} mt-1`}
rows={2} rows={2}
value={description} value={description}
placeholder={t("skill.descriptionHint")} placeholder={t("skill.descriptionHint")}
@@ -499,7 +513,7 @@ export function SkillAddPanel({
<label className={label}> <label className={label}>
{t("skill.content")} * {t("skill.content")} *
<textarea <textarea
className={`${field} mt-0.5 font-mono text-xs`} className={`${field} mt-1 font-mono text-xs`}
rows={10} rows={10}
value={content} value={content}
placeholder={t("skill.contentHint")} placeholder={t("skill.contentHint")}
@@ -509,12 +523,15 @@ export function SkillAddPanel({
<label className={label}> <label className={label}>
{t("form.title")} {t("form.title")}
<input <input
className={`${field} mt-0.5`} className={`${field} mt-1`}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</label> </label>
<div className="flex justify-end"> <div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button <button
type="submit" type="submit"
className="btn-primary text-sm" className="btn-primary text-sm"
@@ -534,7 +551,7 @@ export function SkillAddPanel({
{mode === "existing" && ( {mode === "existing" && (
<div className="space-y-2"> <div className="space-y-2">
<select <select
className={field} className="select w-full"
aria-label={t("form.workspace")} aria-label={t("form.workspace")}
value={laneId ?? ""} value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)} onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
@@ -551,7 +568,6 @@ export function SkillAddPanel({
<ul className="max-h-72 space-y-0.5 overflow-auto"> <ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => ( {skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs"> <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="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted"> <span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")} {t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
@@ -696,7 +712,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
> >
<label className={label}> <label className={label}>
{t("plugin.link")} {t("plugin.link")}
<div className="mt-0.5 flex gap-2"> <div className="mt-1 flex gap-2">
<input <input
className={field} className={field}
value={link} value={link}
@@ -728,10 +744,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
{info && ( {info && (
<div className="space-y-2" data-testid="market-plugins"> <div className="space-y-2" data-testid="market-plugins">
<div> <div>
<p className="text-sm font-medium text-fg-primary"> <p className="text-sm font-medium text-fg-primary">{info.name}</p>
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
{info.name}
</p>
{info.description && <p className={hint}>{info.description}</p>} {info.description && <p className={hint}>{info.description}</p>}
</div> </div>
{info.plugins.length === 0 ? ( {info.plugins.length === 0 ? (
@@ -777,7 +790,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
</li> </li>
))} ))}
</ul> </ul>
<div className="flex justify-end"> <div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="btn-ghost text-sm"
onClick={onClose}
disabled={busy}
>
{t("form.cancel")}
</button>
<button <button
type="button" type="button"
className="btn-primary text-sm" className="btn-primary text-sm"
@@ -853,7 +874,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
<div className="flex justify-end"> <div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button <button
type="submit" type="submit"
className="btn-primary text-sm" className="btn-primary text-sm"
@@ -902,7 +926,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
<input <input
type="file" type="file"
data-testid="file-upload" data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary" className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
onChange={(e) => setFile(e.target.files?.[0] ?? null)} onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/> />
</label> </label>
@@ -920,7 +944,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
className="btn-primary shrink-0 text-sm" className="btn-primary shrink-0 text-sm"
disabled={busy || !file || tooBig} disabled={busy || !file || tooBig}
> >
<Upload className="h-3.5 w-3.5" /> {t("file.upload")} {t("file.upload")}
</button> </button>
</div> </div>
</form> </form>
+4 -2
View File
@@ -4,7 +4,8 @@
* already-fetched data as props (path/data/error) rather than fetching * already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex * itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch. A markdown file gets a Raw / Preview switch * config page) owns the fetch. A markdown file gets a Raw / Preview switch
* (`MarkdownPreview`); Raw is the default. * (`MarkdownPreview`), opening as Settings → Appearance says
* (`lib/markdownView.ts`: Preview by default).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -15,6 +16,7 @@ import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api"; import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives"; import { CopyButton } from "./configPrimitives";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { readMarkdownPreview } from "../../lib/markdownView";
export function FileViewer({ export function FileViewer({
state, state,
@@ -24,7 +26,7 @@ export function FileViewer({
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation("ccConfig"); const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false); const [preview, setPreview] = useState(readMarkdownPreview);
const canPreview = isMarkdown(state.path) && !!state.data; const canPreview = isMarkdown(state.path) && !!state.data;
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose); useDialog(true, panelRef, onClose);
@@ -0,0 +1,276 @@
/**
* @file AgentDetails.tsx
* @description What the chat shows of a subagent, like Claude Desktop: an
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
* the task it was given and the report it returned — read off the call, its
* result (`tool_result.agent`) and, for a background agent, the
* `<task-notification>` that closed it — and, one click further, the
* subagent's own conversation as a small chat right there in the step, every
* step of it. The chat's owner (ConversationView) supplies the links through
* `AgentContext`: which agent transcript a call launched, the notifications
* seen, and how to render one agent's conversation (a nested
* ConversationView — passed in, so this module never imports it back).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { createContext, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
import { fmt, formatModelName, formatMs } from "../../lib/format";
import { MarkdownContent } from "./MarkdownContent";
export type AgentStatus = "running" | "done" | "error" | "stopped";
/** One subagent as the chat knows it from the call that launched it. */
export interface AgentRef {
/** The Agent/Task `tool_use` id. */
toolUseId: string;
/** Its own transcript's `agent_id`; null while nothing names it yet. */
agentId: string | null;
type: string;
description: string;
model: string | null;
status: AgentStatus;
background: boolean;
prompt: string | null;
report: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
/** What a `<task-notification>` said about a background agent. */
export interface AgentNotice {
status: string;
result: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
export interface AgentLinks {
/** tool_use id → agent transcript id, from the session's transcript list. */
ids: Map<string, string>;
/** tool_use id → the notification that closed that background agent. */
notices: Map<string, AgentNotice>;
/** The session runs in a pane: a call with no result yet still works. */
live: boolean;
/** One agent's own conversation, as a chat that fills its box. */
render?: (agent: AgentRef) => React.ReactNode;
}
export const AgentContext = createContext<AgentLinks>({
ids: new Map(),
notices: new Map(),
live: false,
});
const AGENT_TOOLS = new Set(["Agent", "Task"]);
export const isAgentCall = (b: TranscriptContent) =>
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
// The result names the agent's own transcript (`agent-<id>.jsonl`).
const AGENT_ID = /agentId:\s*([\w-]+)/;
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
const tag = (text: string, name: string) =>
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
/** The time, tokens and tool calls of a `<usage>` block, in either form
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
const body = text.match(USAGE)?.[1] ?? "";
const num = (...names: string[]) => {
for (const n of names) {
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
if (v) return Number(v);
}
return null;
};
return {
durationMs: num("duration_ms"),
tokens: num("subagent_tokens", "total_tokens"),
toolUses: num("tool_uses"),
};
}
/** Each `<task-notification>` in the transcript, by the call it closes. */
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
const out = new Map<string, AgentNotice>();
for (const msg of messages) {
if (msg.type !== "user") continue;
for (const c of msg.content) {
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
for (const part of c.text.split("<task-notification>").slice(1)) {
const id = tag(part, "tool-use-id");
if (!id) continue;
out.set(id, {
status: tag(part, "status") ?? "completed",
result: tag(part, "result"),
...parseUsage(part),
});
}
}
}
return out;
}
/** A report without the footer Claude Code appends for the orchestrator. */
function stripFooter(text: string): string {
return text
.replace(USAGE, "")
.replace(/^agentId:.*$/m, "")
.trim();
}
/** Everything the chat can say about the subagent `block` launched. */
export function agentRef(
block: TranscriptContent,
result: TranscriptContent | null | undefined,
links: AgentLinks
): AgentRef {
const raw = block.input as Record<string, unknown> | undefined;
const input = raw && !("_truncated" in raw) ? raw : {};
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
const id = block.id ?? "";
const facts = result?.agent;
const output = result?.output ?? "";
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
const notice = background ? links.notices.get(id) : undefined;
const usage = !background && output ? parseUsage(output) : null;
let status: AgentStatus;
if (result?.is_error) status = "error";
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
else if (notice && notice.status !== "completed")
status = notice.status === "failed" ? "error" : "stopped";
else status = "done";
return {
toolUseId: id,
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
type: str(input.subagent_type) ?? "Agent",
description: str(input.description) ?? "",
model: facts?.model ?? str(input.model),
status,
background,
prompt: str(input.prompt),
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
};
}
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
};
/** One muted line: its status as an icon, then type · model · time ·
* tokens · tool calls, as far as they are known. */
export function AgentFacts({ agent }: { agent: AgentRef }) {
const { t } = useTranslation("sessions");
const parts = [
agent.type,
agent.model && formatModelName(agent.model),
agent.background && t("conversation.agent.background"),
agent.durationMs != null && formatMs(agent.durationMs),
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
].filter(Boolean);
return (
<div
data-testid="agent-facts"
title={t(`conversation.agent.status.${agent.status}`)}
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
>
{STATUS_ICON[agent.status]}
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
<span className="truncate">{parts.join(" · ")}</span>
</div>
);
}
const linkClass =
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
/** The subagent's own conversation, a thread hung under its step by a line
* on the left — as tall as it is, up to 24rem, then it scrolls. */
export function AgentChatBox({ agent }: { agent: AgentRef }) {
const links = useContext(AgentContext);
return (
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
{links.render?.(agent)}
</div>
);
}
/** An Agent/Task step opened in the chat: one line of facts, its report as
* prose, and "View conversation ›", which puts the agent's own thread (its
* task, every step, its reply) in the report's place. With no transcript
* to show, the task it was given opens on its own instead. */
export function AgentStepBody({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const links = useContext(AgentContext);
const agent = agentRef(block, result, links);
const canOpen = !!agent.agentId && !!links.render;
const [thread, setThread] = useState(false);
const [task, setTask] = useState(false);
return (
<div data-testid="agent-step" className="space-y-1.5">
<AgentFacts agent={agent} />
{thread ? (
<AgentChatBox agent={agent} />
) : (
<>
{!canOpen && agent.prompt && task && (
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
<MarkdownContent text={agent.prompt} dense />
</div>
)}
{agent.report && (
<div
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
>
<MarkdownContent text={agent.report} dense />
</div>
)}
</>
)}
{canOpen ? (
<button
type="button"
data-testid="agent-open"
aria-expanded={thread}
onClick={() => setThread(!thread)}
className={linkClass}
>
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
</button>
) : (
agent.prompt && (
<button
type="button"
aria-expanded={task}
onClick={() => setTask(!task)}
className={linkClass}
>
{t("conversation.agent.prompt")}
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
</button>
)
)}
</div>
);
}
@@ -17,7 +17,9 @@
* A caller that knows the session has ended passes `ended`: nothing more is * A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the * being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or * sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once. * the view being shown again still fetch the tail at once. The main
* transcript hands MessageList its `sessionId`, so the list can read its
* background agents' own transcripts for their progress.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -71,11 +73,17 @@ import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents"; import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList, promptTokens } from "./MessageList"; import { MessageList, promptTokens } from "./MessageList";
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
import { useSpin } from "../../hooks/useSpin"; import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen"; import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle"; import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types"; import type {
TranscriptMessage,
TranscriptInfo,
TranscriptResult,
WSMessage,
} from "../../lib/types";
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse / // Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
// PostToolUse / Stop, which means a user-typed message (no hook) and any // PostToolUse / Stop, which means a user-typed message (no hook) and any
@@ -93,6 +101,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
// at most this often: the first event still fetches at once, the last is never // 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. // lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500; const EVENT_FETCH_MS = 1500;
// The server saw this session's transcript grow (`transcript_changed`, itself
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
const PUSH_FETCH_MS = 300;
interface ConversationViewProps { interface ConversationViewProps {
sessionId: string; sessionId: string;
@@ -100,8 +111,15 @@ interface ConversationViewProps {
/** Fill the parent as a chat (a workspace's session view): no toolbar, no /** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */ * page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean; fill?: boolean;
/** A subagent's conversation inside its step: no toolbar, as tall as its
* content up to 24rem, then it scrolls. */
compact?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */ /** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void; onOpenFile?: (path: string) => void;
/** Opens an artifact card's file in the chat's artifact panel. */
onOpenArtifact?: (path: string) => void;
/** The messages on screen (for an artifact's versions), as they change. */
onMessages?: (messages: TranscriptMessage[]) => void;
/** The session runs in a pane: show the step in progress. */ /** The session runs in a pane: show the step in progress. */
live?: boolean; live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */ /** Changed (a chat's send): back to the end, and stick to it. */
@@ -125,7 +143,10 @@ function ConversationViewPane({
sessionId, sessionId,
initialTranscriptId, initialTranscriptId,
fill = false, fill = false,
compact = false,
onOpenFile, onOpenFile,
onOpenArtifact,
onMessages,
live = false, live = false,
follow = 0, follow = 0,
waiting = false, waiting = false,
@@ -137,19 +158,21 @@ function ConversationViewPane({
}: ConversationViewProps) { }: ConversationViewProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]); const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const shown = messages;
// A live pane's session has no row until its first hook — and Claude Code // 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 // 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. // other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false); const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]); useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => onMessages?.(shown), [shown, onMessages]);
useEffect(() => { useEffect(() => {
if (!onContext) return; if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) { for (let i = shown.length - 1; i >= 0; i--) {
const m = messages[i]!; const m = shown[i]!;
if (m.type === "assistant" && m.usage) if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null); return onContext(promptTokens(m.usage), m.model ?? null);
} }
}, [messages, onContext]); }, [shown, onContext]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false);
@@ -157,6 +180,8 @@ function ConversationViewPane({
initialTranscriptId ?? null initialTranscriptId ?? null
); );
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]); const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false); const [showNewMsg, setShowNewMsg] = useState(false);
@@ -184,9 +209,31 @@ function ConversationViewPane({
// Refresh-button spinner state - separate from initial `loading` so the // Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh. // existing skeleton doesn't blink during a manual refresh.
// A subagent's own conversation opens inside its step as a small chat: this
// same view, on that agent's transcript.
const renderAgent = useCallback(
(a: AgentRef) => (
<ConversationView
key={a.agentId}
sessionId={sessionId}
initialTranscriptId={a.agentId}
compact
live={live && a.status === "running"}
ended={a.status !== "running"}
/>
),
[sessionId, live]
);
const agentLinks = useMemo<AgentLinks>(() => {
const ids = new Map<string, string>();
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
return { ids, notices: agentNotices(shown), live, render: renderAgent };
}, [transcripts, shown, live, renderAgent]);
// Load available transcript list (also rescanned on a short interval so // Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload). // newly-spawned subagents appear in the dropdown without a page reload).
useEffect(() => { useEffect(() => {
if (!sessionId) return;
let cancelled = false; let cancelled = false;
async function loadTranscripts() { async function loadTranscripts() {
try { try {
@@ -226,6 +273,12 @@ function ConversationViewPane({
firstLineRef.current = 0; firstLineRef.current = 0;
async function load() { async function load() {
// No session yet: no history.
if (!sessionId) {
setMessages([]);
setLoading(false);
return;
}
try { try {
setError(null); setError(null);
setLoading(true); setLoading(true);
@@ -237,6 +290,7 @@ function ConversationViewPane({
if (cancelled) return; if (cancelled) return;
setNotStarted(false); setNotStarted(false);
setMessages(result.messages); setMessages(result.messages);
setLeadEdits(result.turn_edits);
setTotal(result.total); setTotal(result.total);
setHasMore(result.has_more); setHasMore(result.has_more);
lastLineRef.current = result.last_line; lastLineRef.current = result.last_line;
@@ -269,6 +323,7 @@ function ConversationViewPane({
// parsed message we've seen. The server already de-overlaps via // parsed message we've seen. The server already de-overlaps via
// afterLine, so we can safely append. // afterLine, so we can safely append.
const fetchNewMessages = useCallback(async () => { const fetchNewMessages = useCallback(async () => {
if (!sessionId) return;
if (fetchingRef.current) { if (fetchingRef.current) {
// Coalesce: remember a trigger arrived during this fetch and re-run // Coalesce: remember a trigger arrived during this fetch and re-run
// exactly once when the in-flight request settles. // exactly once when the in-flight request settles.
@@ -296,6 +351,7 @@ function ConversationViewPane({
// Seed the view in a single render so the user sees the whole // Seed the view in a single render so the user sees the whole
// catch-up batch instead of a blank panel followed by a partial one. // catch-up batch instead of a blank panel followed by a partial one.
setMessages(result.messages); setMessages(result.messages);
setLeadEdits(result.turn_edits);
firstLineRef.current = result.first_line; firstLineRef.current = result.first_line;
setHasMore(result.has_more); setHasMore(result.has_more);
} else { } else {
@@ -330,7 +386,12 @@ function ConversationViewPane({
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS), () => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
[fetchNewMessages] [fetchNewMessages]
); );
const fetchPushed = useMemo(
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]); useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
useEffect(() => { useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => { const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab or hidden pane: catching up once on show is enough. // Hidden tab or hidden pane: catching up once on show is enough.
@@ -339,13 +400,14 @@ function ConversationViewPane({
fetchSoon(); fetchSoon();
return; return;
} }
if (msg.type !== "new_event") return; if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
const data = msg.data as { session_id?: string }; const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return; if (data.session_id !== sessionId) return;
fetchSoon(); if (msg.type === "transcript_changed") fetchPushed();
else fetchSoon();
}); });
return unsubscribe; return unsubscribe;
}, [sessionId, fetchSoon, onScreen]); }, [sessionId, fetchSoon, fetchPushed, onScreen]);
onShowRef.current = () => { onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages(); if (document.visibilityState === "visible") void fetchNewMessages();
@@ -434,6 +496,7 @@ function ConversationViewPane({
firstLineRef.current = result.first_line; firstLineRef.current = result.first_line;
setMessages((prev) => [...result.messages, ...prev]); setMessages((prev) => [...result.messages, ...prev]);
setLeadEdits(result.turn_edits);
setHasMore(result.has_more); setHasMore(result.has_more);
// Preserve scroll position (don't jump to top) // Preserve scroll position (don't jump to top)
@@ -507,7 +570,7 @@ function ConversationViewPane({
// Auto-scroll to bottom after initial load // Auto-scroll to bottom after initial load
useEffect(() => { useEffect(() => {
if (!loading && messages.length > 0) { if (!loading && shown.length > 0) {
scrollToBottom(); scrollToBottom();
} }
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps }, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -516,7 +579,7 @@ function ConversationViewPane({
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}> <div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can {/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */} refresh even when no messages have streamed yet. */}
{!loading && !fill && ( {!loading && !fill && !compact && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0"> <div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && ( {transcripts.length > 1 && (
<div className="relative"> <div className="relative">
@@ -564,7 +627,13 @@ function ConversationViewPane({
ref={scrollContainerRef} ref={scrollContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]" className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }} style={
compact
? { maxHeight: "24rem" }
: fill
? { minHeight: 0 }
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
}
> >
{/* A chat keeps its first turn clear of the pane's top-right controls {/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */} (the outline's file button floats there). */}
@@ -587,10 +656,12 @@ function ConversationViewPane({
)} )}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm"> <div
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
>
{t("conversation.loading")} {t("conversation.loading")}
</div> </div>
) : messages.length === 0 && notStarted ? ( ) : shown.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center"> <div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p> <p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted"> <p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -606,7 +677,7 @@ function ConversationViewPane({
</button> </button>
)} )}
</div> </div>
) : messages.length === 0 ? ( ) : shown.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center"> <div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p> <p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted"> <p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -615,13 +686,20 @@ function ConversationViewPane({
</div> </div>
) : ( ) : (
<MessageList <MessageList
messages={messages} messages={shown}
loading={false} loading={false}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
live={live} live={live}
waiting={waiting} waiting={waiting}
onOpenTerminal={onOpenTerminal} onOpenTerminal={onOpenTerminal}
stopped={stopped} stopped={stopped}
agentLinks={agentLinks}
compact={compact}
leadEdits={leadEdits}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
/> />
)} )}
</div> </div>
@@ -648,7 +726,7 @@ function ConversationViewPane({
)} )}
{/* New messages indicator */} {/* New messages indicator */}
{showNewMsg && !fill && ( {showNewMsg && !fill && !compact && (
<button <button
onClick={() => { onClick={() => {
scrollToBottom(); scrollToBottom();
@@ -4,7 +4,7 @@
* subset of CommonMark + GFM that actually appears in Claude Code transcripts: * subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes, * fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links, * horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. A file path — absolute, or relative with a folder and * and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in * 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` * 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 * when the page provides one (the page resolves a relative one against its own
@@ -272,8 +272,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder. // (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
const REL_PATH_RE = 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; /^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
const WIN_PATH_RE =
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
// In inline code the whole text is the path, so a Windows one may hold spaces.
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
/** A path at the start of `text`, absolute or relative, or null. */ /** A path at the start of `text`, absolute or relative, or null. */
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null; const matchPath = (text: string) =>
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */ /** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, ""); export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
// A markdown link target that is a page, not a file. // A markdown link target that is a page, not a file.
@@ -393,7 +399,13 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
{codeM[1]} {codeM[1]}
</code> </code>
); );
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code); push(
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
)
);
i += codeM[0].length; i += codeM[0].length;
continue; continue;
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
/**
* @file AgentDetails.test.tsx
* @description Verifies what the chat shows of a subagent: an Agent step's
* facts, task and report (from its result, or the task-notification that
* closed a background one), and that "View conversation" opens that
* agent's own conversation in place, rendered by the chat's owner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageList } from "../MessageList";
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
const call: TranscriptContent = {
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
};
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
ids: new Map(),
notices: new Map(),
live: false,
...p,
});
describe("agentRef", () => {
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
const a = agentRef(
call,
{
type: "tool_result",
id: "t1",
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
agent: {
id: "a1b2",
model: "claude-opus-5-5",
status: "completed",
duration_ms: 65_000,
tokens: 12_000,
tool_uses: 7,
},
},
links()
);
expect(a).toMatchObject({
agentId: "a1b2",
type: "Explore",
status: "done",
background: false,
prompt: "Read **server/**",
report: "The hooks live in server/routes/hooks.js.",
durationMs: 65_000,
tokens: 12_000,
toolUses: 7,
});
});
it("closes a background agent with its notification's report and usage", () => {
const notice = m({
content: [
{
type: "text",
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
},
],
});
const launched: TranscriptContent = {
type: "tool_result",
id: "t1",
output: "Async agent launched successfully.\nagentId: a9b4",
};
const running = agentRef(call, launched, links({ live: true }));
expect(running).toMatchObject({ status: "running", background: true, report: null });
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
expect(done).toMatchObject({
agentId: "a9b4",
status: "done",
report: "All done",
tokens: 88007,
toolUses: 31,
durationMs: 132350,
});
});
it("finds the transcript from the session's list when the result names none", () => {
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
});
it("parses the line form of a usage block", () => {
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
tokens: 500,
toolUses: 3,
durationMs: 900,
});
});
});
describe("an Agent step in the chat", () => {
it("shows the agent's facts and report, and opens its conversation in place", () => {
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
render(
<MessageList
loading={false}
agentLinks={links({ render: render_ })}
messages={[
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
m({
content: [
{
type: "tool_result",
id: "t1",
output: "Found **3** hooks",
agent: {
id: "a1b2",
model: null,
status: "completed",
duration_ms: null,
tokens: null,
tool_uses: 4,
},
},
],
}),
]}
/>
);
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
expect(screen.queryByTestId("agent-chat")).toBeNull();
fireEvent.click(screen.getByTestId("agent-open"));
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
// The thread takes the report's place.
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
});
});
@@ -0,0 +1,74 @@
/**
* @file ConversationView.pushed.test.tsx
* @description The server says a session's transcript grew
* (`transcript_changed`): the chat showing that session reads the new lines
* at once, and again within a short window after a burst — not at the hook
* throttle's 1.5 s pace — while another session's notice fetches nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
import { api } from "../../../lib/api";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-21T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
const pushed = (session_id: string) =>
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView on transcript_changed", () => {
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
pushed("s1");
expect(calls()).toBe(before + 1);
pushed("s1");
pushed("s1");
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(calls()).toBe(before + 2);
});
it("ignores another session's transcript", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
pushed("s2");
await act(async () => {});
expect(calls()).toBe(before);
});
});
@@ -36,6 +36,20 @@ describe("file links", () => {
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]); expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
}); });
it("opens a Windows path, bare or in inline code", () => {
const open = withOpen(
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
);
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
expect(open.mock.calls).toEqual([
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
["C:/src/a.ts"],
["D:\\My Docs\\b.md"],
]);
});
it("opens a markdown link to a file instead of navigating", () => { 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)"); const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
fireEvent.click(screen.getByRole("button", { name: "the panel" })); fireEvent.click(screen.getByRole("button", { name: "the panel" }));
@@ -0,0 +1,118 @@
/**
* @file MessageList.agents.test.tsx
* @description Verifies a live chat's background-agents card reads each
* running agent's own transcript (by the agentId its launch result names)
* and shows the step it is on and its context size (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageList, agentProgress } from "../MessageList";
import { api } from "../../../lib/api";
import type { TranscriptMessage } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
});
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
// The subagent's own transcript: its task, then a search still running.
const tail: TranscriptMessage[] = [
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
content: [
{
type: "tool_use",
id: "g1",
name: "Grep",
input: { pattern: "copyId", description: "Searching for copyId" },
},
],
}),
];
const main: TranscriptMessage[] = [
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
}),
m({
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
]),
},
],
}),
m({
type: "assistant",
sender: "assistant",
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting" }],
}),
];
describe("background agents' progress", () => {
const rects = Element.prototype.getClientRects;
beforeEach(() => {
// jsdom lays nothing out: make the card count as on screen.
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
vi.mocked(api.sessions.transcript).mockResolvedValue({
messages: tail,
total: 2,
has_more: false,
first_line: 1,
last_line: 2,
});
});
afterEach(() => {
Element.prototype.getClientRects = rects;
vi.mocked(api.sessions.transcript).mockReset();
});
it("reads the step an agent is on and its context", () => {
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
});
it("shows them on the agent's line, from its own transcript", async () => {
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
await waitFor(() =>
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
"Searching for copyId"
)
);
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
agent_id: "a4118367b503adecc",
limit: 20,
});
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
});
it("reads nothing without the session", () => {
render(<MessageList live loading={false} messages={main} />);
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
expect(api.sessions.transcript).not.toHaveBeenCalled();
});
});
@@ -4,7 +4,10 @@
* Desktop's "Edited N files" card: one row per file with the lines added and * Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, a row click handed * 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` * to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box. * attachments for a prompt box. Files a subagent changed (its Agent
* result's `edits`, read from its own transcript) are counted in the same
* card, never as an artifact; so are the files a turn changed before the
* loaded page began (`leadEdits`, the page's `turn_edits`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
@@ -46,7 +49,7 @@ describe("the Edited N files card", () => {
user("fix it"), user("fix it"),
call("1", "Edit", "/w/a.ts"), call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }), result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"), call("2", "Write", "/w/b.json"),
result("2", { added: 10, removed: 0 }), result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"), call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }), result("3", { added: 2, removed: 2 }),
@@ -65,8 +68,35 @@ describe("the Edited N files card", () => {
expect(within(card).getByText("+15")).toBeTruthy(); expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md")); fireEvent.click(within(card).getByTitle("/w/b.json"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md"); expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
});
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
const onOpenFile = vi.fn();
const onOpenArtifact = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
messages={[
user("make a page"),
call("1", "Write", "/w/page.html"),
result("1", { added: 20, removed: 0 }),
call("2", "Edit", "/w/App.tsx"),
result("2", { added: 1, removed: 1 }),
user("thanks"),
]}
/>
);
const cards = screen.getAllByTestId("artifact-card");
// A .tsx it only edited is project code, not an artifact.
expect(cards).toHaveLength(1);
expect(cards[0]!.textContent).toContain("page");
fireEvent.click(cards[0]!);
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
}); });
it("drags a row as its @path attachment, and the header as every file", () => { it("drags a row as its @path attachment, and the header as every file", () => {
@@ -77,7 +107,7 @@ describe("the Edited N files card", () => {
user("fix it"), user("fix it"),
call("1", "Edit", "/w/a.ts"), call("1", "Edit", "/w/a.ts"),
result("1", { added: 1, removed: 0 }), result("1", { added: 1, removed: 0 }),
call("2", "Write", "/w/b.md"), call("2", "Write", "/w/b.json"),
result("2", { added: 2, removed: 0 }), result("2", { added: 2, removed: 0 }),
]} ]}
/> />
@@ -90,14 +120,81 @@ describe("the Edited N files card", () => {
}); });
return data; return data;
}; };
const one = drag(within(card).getByTitle("/w/b.md")); const one = drag(within(card).getByTitle("/w/b.json"));
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([ expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
{ path: "@/w/b.md", name: "b.md" }, { path: "@/w/b.json", name: "b.json" },
]); ]);
expect(one["text/plain"]).toBe("/w/b.md"); expect(one["text/plain"]).toBe("/w/b.json");
const all = drag(within(card).getByText("Edited 2 files")); const all = drag(within(card).getByText("Edited 2 files"));
expect( expect(
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path) JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.md"]); ).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
it("counts the files a subagent changed, beside the turn's own edits", () => {
render(
<MessageList
loading={false}
messages={[
user("run the task"),
call("1", "Edit", "/w/a.go"),
result("1", { added: 1, removed: 0 }),
{
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
} as TranscriptMessage,
{
type: "user",
sender: "tool",
timestamp: at,
content: [
{
type: "tool_result",
id: "2",
output: "done",
agent: { id: "x1" },
edits: [
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
],
},
],
} as TranscriptMessage,
user("thanks"),
]}
/>
);
const card = screen.getByTestId("edited-files");
expect(card.textContent).toContain("Edited 3 files");
expect(within(card).getByText("b_test.go")).toBeTruthy();
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
// A page only a subagent wrote is listed, not shown as an artifact card.
expect(within(card).getByText("report.html")).toBeTruthy();
expect(screen.queryByTestId("artifact-card")).toBeNull();
});
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
render(
<MessageList
loading={false}
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
messages={[
call("1", "Write", "/w/handler_test.go"),
result("1", { added: 113, removed: 0 }),
user("what changed?"),
call("2", "Edit", "/w/x.go"),
result("2", { added: 1, removed: 0 }),
]}
/>
);
const [first, second] = screen.getAllByTestId("edited-files");
expect(first!.textContent).toContain("Edited 2 files");
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
// The next turn's card is its own.
expect(second!.textContent).toContain("Edited 1 file");
}); });
}); });
@@ -5,12 +5,13 @@
* layout), Main agent / System under their own label — instead of treating * layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) — * every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line; a typed `/command` or `@path` * and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip. * shows as a chip. A live chat lists the background subagents still running
* and does not call their turn done (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList"; import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types"; import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage { function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -137,7 +138,28 @@ describe("MessageList — sender attribution", () => {
]} ]}
/> />
); );
expect(screen.getByText(/↓ 123/)).toBeTruthy(); // In the hover bar's tooltip, beside the model and time.
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
});
it("shows a local command as one line with its output, never its caveat", () => {
const line = (text: string, sender: "user" | "system" = "user") =>
msg({ type: "user", sender, content: [{ type: "text", text }] });
render(
<MessageList
loading={false}
messages={[
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
line("<command-name>/model</command-name><command-args>opus</command-args>"),
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
]}
/>
);
const row = screen.getByTestId("chat-command");
expect(row).toHaveTextContent("/model opus");
expect(row).toHaveTextContent("Set model to Opus");
expect(screen.queryByText(/Caveat/)).toBeNull();
expect(screen.queryByTestId("chat-user")).toBeNull();
}); });
it("ends a live chat with the step still running", () => { it("ends a live chat with the step still running", () => {
@@ -167,7 +189,9 @@ describe("MessageList — sender attribution", () => {
const row = screen.getByTestId("chat-running-step"); const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite"); expect(row).toHaveTextContent("Run the full client suite");
// Like the terminal: the turn's time since the prompt, and its output. // Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/); // (the output shows on hover).
expect(row).toHaveTextContent(/1m \ds/);
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
// Its result arrived: nothing is running any more. // Its result arrived: nothing is running any more.
rerender( rerender(
<MessageList <MessageList
@@ -340,7 +364,88 @@ describe("MessageList — sender attribution", () => {
content: [{ type: "text", text: "[Request interrupted by user]" }], content: [{ type: "text", text: "[Request interrupted by user]" }],
}); });
expect(state([prompt, call, result, stopped])).toBeNull(); expect(state([prompt, call, result, stopped])).toBeNull();
// A local command (`/clear`) is answered by the CLI itself: no turn.
const local = (text: string) =>
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
const clear = local("<command-name>/clear</command-name>");
const out = local("<local-command-stdout></local-command-stdout>");
expect(state([clear, out])).toBeNull();
// A prompt command (a skill) is still answered by the model.
expect(state([clear])).toEqual(thinking);
// Silent for 15 minutes with nothing running: the turn died. // Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull(); expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
}); });
describe("background subagents", () => {
const at = new Date().toISOString();
const launch = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
});
const launched = msg({
timestamp: at,
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
},
],
});
const reply = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting for the agent" }],
});
const notified = msg({
sender: "system",
timestamp: at,
content: [
{
type: "text",
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
},
],
});
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
it("lists an agent until its notification names the call", () => {
const results = (m: TranscriptMessage[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
const all = [...base, notified];
expect(backgroundAgents(all, results(all))).toEqual([]);
});
it("does not call the turn done while the agent works", () => {
const { rerender } = render(<MessageList live loading={false} messages={base} />);
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
"ExploreChecking copyId coverage"
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
});
it("shows nothing once the session is no longer live", () => {
render(<MessageList loading={false} messages={base} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
});
});
}); });
@@ -103,6 +103,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react"; import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib/clipboard";
// ───────────────────────── Copy button ───────────────────────── // ───────────────────────── Copy button ─────────────────────────
@@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
async function copy() { async function copy() {
try { if (!(await copyText(text, true))) return;
await navigator.clipboard.writeText(text); setCopied(true);
setCopied(true); setTimeout(() => setCopied(false), 1500);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API can fail in insecure contexts - silently ignore.
}
} }
return ( return (
+30 -1
View File
@@ -9,7 +9,8 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an * 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/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 * HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git). There is no AI tool picker: the lane is created with the * Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own * default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either * AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete * mode's path field can be filled by typing, by the CwdAutocomplete
@@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup"; import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton"; import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import type { CwdSuggestion } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types"; import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
@@ -116,6 +118,9 @@ export function AddLaneModal({
// server asked for one — a login for the URL's https host. // server asked for one — a login for the URL's https host.
const [cloneUrl, setCloneUrl] = useState(""); const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState(""); const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{ const [login, setLogin] = useState<{
host: string; host: string;
username: string; username: string;
@@ -159,6 +164,7 @@ export function AddLaneModal({
setMode("repo"); setMode("repo");
setCloneUrl(""); setCloneUrl("");
setCloneName(""); setCloneName("");
setClonePartial(false);
setLogin(null); setLogin(null);
setSshRefused(false); setSshRefused(false);
setSourceRepo(""); setSourceRepo("");
@@ -236,13 +242,17 @@ export function AddLaneModal({
const url = cloneUrl.trim(); const url = cloneUrl.trim();
if (!url) return; if (!url) return;
setSshRefused(false); setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
try { try {
const result = await api.lanes.clone({ const result = await api.lanes.clone({
progressToken,
url, url,
parent: repo, parent: repo,
name: cloneName.trim() || undefined, name: cloneName.trim() || undefined,
title: name || undefined, title: name || undefined,
tool, tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token ...(login?.username.trim() && login.token
? { ? {
credentials: { credentials: {
@@ -274,6 +284,8 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
setBusy(false); setBusy(false);
} finally {
setCloneToken(null);
} }
return; return;
} }
@@ -547,6 +559,23 @@ export function AddLaneModal({
spellCheck={false} 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" className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/> />
<label
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="add-lane-clone-partial"
checked={clonePartial}
onChange={(e) => setClonePartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
</div> </div>
)} )}
@@ -0,0 +1,290 @@
/**
* @file CloneHereDialog.tsx
* @description The Files page's **New → Clone repository**: clones a URL into
* the folder the File Explorer has open, through the same
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* A running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { toast } from "../../lib/toast";
type Login = { host: string; username: string; token: string; remember: boolean };
export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
onReopen,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
/** Show the dialog again from the background chip. */
onReopen: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [login, setLogin] = useState<Login | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
/** Where the running clone goes (the open folder may change meanwhile). */
const [target, setTarget] = useState("");
// Read by the request's own continuation and by the reset below.
const openRef = useRef(open);
openRef.current = open;
const busyRef = useRef(false);
/** A clone failed while hidden: the next open shows that, not a blank form. */
const keepRef = useRef(false);
useDialog(open, panelRef, onClose);
useEffect(() => {
if (!open || busyRef.current) return;
if (keepRef.current) {
keepRef.current = false;
return;
}
setUrl("");
setName("");
setLogin(null);
setSshRefused(false);
setBusy(false);
setError(null);
}, [open, parent]);
const folder = name.trim() || cloneDirName(url);
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
if (!open) {
if (!busy || !token) return null;
return createPortal(
<button
type="button"
data-testid="fx-clone-chip"
onClick={onReopen}
title={into}
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{t("clone.cloning")} {into.split("/").pop()}
</span>
</span>
<CloneProgress token={token} />
</button>,
document.body
);
}
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
const progressToken = newCloneToken();
busyRef.current = true;
setBusy(true);
setToken(progressToken);
setTarget(into);
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
progressToken,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
busyRef.current = false;
setBusy(false);
setToken(null);
onCloned(result.lane, result.path);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
let message: string;
if (e.code === "ECREDENTIALS") {
message = 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);
message = t("clone.sshRefused", { host });
} else {
message = err instanceof Error ? err.message : String(err);
}
setError(message);
busyRef.current = false;
setBusy(false);
setToken(null);
if (!openRef.current) {
keepRef.current = true;
toast.error(message);
}
}
};
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="fx-clone-dialog"
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2
id={titleId}
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="space-y-3"
>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
{t("clone.urlLabel")}
</label>
<input
id={`${titleId}-url`}
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://host/owner/repo.git"
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
{t("clone.nameLabel")}
</label>
<input
id={`${titleId}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={cloneDirName(url)}
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 break-all text-[11px] text-fg-muted">
{t("clone.parentHint", { path: into })}
</p>
</div>
{login && (
<div
data-testid="fx-clone-login"
className="space-y-2 rounded-md border border-border p-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="username"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<input
aria-label={t("clone.token")}
placeholder={t("clone.token")}
type="password"
autoComplete="current-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
disabled={busy}
/>
{t("clone.remember")}
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
{sshRefused && (
<a href="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
)}
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{busy ? t("clone.background") : t("fx.cancel")}
</button>
<button
type="submit"
disabled={busy || !url.trim()}
className="btn-primary text-xs"
data-testid="fx-clone-submit"
>
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
</button>
</div>
</form>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,93 @@
/**
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
const POLL_MS = 1000;
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
</p>
);
}
const phase = view.phase
? view.phase === "other"
? view.label || ""
: t(`clone.phase.${view.phase}`)
: t("clone.connecting");
const percent = view.percent ?? 0;
return (
<div data-testid="clone-progress" className="space-y-1">
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
<span className="truncate">
{phase}
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
)}
</p>
{view.phase && (
<div
className="h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={phase}
>
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
</div>
)}
</div>
);
}
@@ -4,18 +4,62 @@
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and * (`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 * 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 * (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
* URLs with it, like Add workspace → Clone. * URLs with it, like Add workspace → Clone. Before anything runs, each URL
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
* link while the account has no SSH key is not cloned at all — the user is
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react"; import { Check, Loader2, X } from "lucide-react";
import { api, type LaneSourcesView } from "../../lib/api"; import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string }; type Row = {
url: string;
state: "wait" | "busy" | "done" | "failed";
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean }; type Login = { host: string; username: string; token: string; remember: boolean };
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
try {
const u = new URL(url);
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
} catch {
/* not a URL yet — the server's allowlist answers that one */
}
return null;
}
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
function missingFor(
url: string,
id: GitIdentity | null
): { kind: "ssh" | "https"; host: string } | null {
const target = cloneTarget(url);
if (!id || !target) return null;
if (target.kind === "ssh") {
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
return keys > 0 ? null : target;
}
return id.https.some((h) => h.host === target.host) ? null : target;
}
export function CloneSourcesForm({ export function CloneSourcesForm({
laneId, laneId,
parent, parent,
@@ -31,6 +75,25 @@ export function CloneSourcesForm({
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null); const [login, setLogin] = useState<Login | null>(null);
const [partial, setPartial] = useState(false);
const [identity, setIdentity] = useState<GitIdentity | null>(null);
// An unknown identity (an older server, a failed read) checks nothing: the
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
const loadIdentity = useCallback(
() =>
Promise.resolve()
.then(() => api.gitIdentity.get())
.then(setIdentity)
.catch(() => {}),
[]
);
useEffect(() => {
void loadIdentity();
const onFocus = () => void loadIdentity();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [loadIdentity]);
const urls = (s: string) => [ const urls = (s: string) => [
...new Set( ...new Set(
@@ -44,16 +107,27 @@ export function CloneSourcesForm({
const cloneAll = async (list: string[]) => { const cloneAll = async (list: string[]) => {
if (busy || !list.length) return; if (busy || !list.length) return;
setBusy(true); setBusy(true);
const next: Row[] = list.map((url) => ({ url, state: "wait" })); // An SSH link with no key on the account would only fail at the host:
// it is not tried, and its row says what to set up first.
const next: Row[] = list.map((url) => {
const miss = missingFor(url, identity);
return miss?.kind === "ssh"
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
: { url, state: "wait" };
});
const show = () => setRows([...next]); const show = () => setRows([...next]);
show(); show();
let asked: Login | null = null; let asked: Login | null = null;
for (const row of next) { for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy"; row.state = "busy";
row.token = newCloneToken();
show(); show();
try { try {
const view = await api.lanes.cloneFolder(laneId, { const view = await api.lanes.cloneFolder(laneId, {
url: row.url, url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token ...(login?.username.trim() && login.token
? { ? {
credentials: { credentials: {
@@ -80,10 +154,12 @@ export function CloneSourcesForm({
}; };
} else if (e.code === "ESSHAUTH") { } else if (e.code === "ESSHAUTH") {
row.note = t("clone.sshRefused", { host }); row.note = t("clone.sshRefused", { host });
row.ssh = true;
} else { } else {
row.note = e.message || String(err); row.note = e.message || String(err);
} }
} }
row.token = undefined;
show(); show();
} }
if (asked) setLogin(asked); if (asked) setLogin(asked);
@@ -99,6 +175,18 @@ export function CloneSourcesForm({
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url); const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
// Checked as the user types, so a missing key is said before Clone is pressed.
const missing = urls(text)
.map((u) => missingFor(u, identity))
.filter((m): m is NonNullable<typeof m> => m !== null);
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
const settingsLink = (
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
{t("clone.sshSettings")}
</a>
);
return ( return (
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone"> <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> <p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
@@ -112,6 +200,41 @@ export function CloneSourcesForm({
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary" 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> <p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
<label
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="workspace-clone-partial"
checked={partial}
onChange={(e) => setPartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{sshHosts.length > 0 && (
<p
role="alert"
data-testid="workspace-clone-need-ssh"
className="text-[11px] text-status-danger"
>
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
</p>
)}
{httpsHosts.length > 0 && !login && (
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
<button
type="button"
className="underline"
onClick={() =>
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
}
>
{t("home.cloneEnterLogin")}
</button>
</p>
)}
{login && ( {login && (
<div className="space-y-1.5 rounded border border-border-light p-2"> <div className="space-y-1.5 rounded border border-border-light p-2">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -178,7 +301,17 @@ export function CloneSourcesForm({
{r.url} {r.url}
</span> </span>
</span> </span>
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>} {r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
{r.note}
{r.ssh && <> {settingsLink}</>}
</span>
)}
</li> </li>
))} ))}
</ul> </ul>
+337 -81
View File
@@ -22,6 +22,9 @@
* another user's. An admin's Trash opens on one folder per user (with its * another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge * item count); each folder is THAT user's Trash, to restore from or purge
* without waiting the 7 days. * without waiting the 7 days.
* The sidebar nests each workspace under its owner's home (a chevron drops
* them down, remembered per browser); one with no owner home stays in a flat
* Workspaces list.
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*` * Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
* (server/lib/file-explorer.js). * (server/lib/file-explorer.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -82,6 +85,7 @@ import {
type FsTrashItem, type FsTrashItem,
type FsHome, type FsHome,
type FsPlace, type FsPlace,
type GitSource,
isHomePlace, isHomePlace,
} from "../../lib/api"; } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
@@ -91,12 +95,58 @@ import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format"; import { formatBytes } from "../../lib/format";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CloneHereDialog } from "./CloneHereDialog";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
/** One upload batch at most (the server caps one file the same). */ /** One upload batch at most (the server caps one file the same). */
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024; export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
/** Files up to this size travel together, many per request (`uploadBatch`):
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
const SMALL_FILE = 4 * 1024 * 1024;
const PACK_BYTES = 32 * 1024 * 1024;
const PACK_FILES = 1000;
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
const PACK_NAME_BYTES = 400 * 1024;
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
const packs: T[][] = [];
const singles: T[] = [];
let cur: T[] = [];
let bytes = 0;
let names = 0;
for (const f of files) {
if (f.file.size > SMALL_FILE) {
singles.push(f);
continue;
}
const n = f.rel.length * 3 + 64;
if (
cur.length &&
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
) {
packs.push(cur);
cur = [];
bytes = 0;
names = 0;
}
cur.push(f);
bytes += f.file.size;
names += n;
}
if (cur.length) packs.push(cur);
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
return { packs: packs.filter((p) => p.length >= 2), singles };
}
const MIME = "application/x-agenthub-fx"; const MIME = "application/x-agenthub-fx";
const VIEW_KEY = "agenthub.files.view"; const VIEW_KEY = "agenthub.files.view";
const HIDDEN_KEY = "agenthub.files.hidden"; const HIDDEN_KEY = "agenthub.files.hidden";
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
* viewer may not open that home (an analyst's view of other users). */
type UserRow = FsHome & { noHome?: boolean };
type Clip = { place: FsPlace; paths: string[]; cut: boolean }; type Clip = { place: FsPlace; paths: string[]; cut: boolean };
// The clipboard outlives the dialog and a workspace switch, like a desktop's. // The clipboard outlives the dialog and a workspace switch, like a desktop's.
@@ -234,7 +284,7 @@ export function FileBrowser({
/** A home to open on (`me`, or another user's for an admin). */ /** A home to open on (`me`, or another user's for an admin). */
initialHome?: string | null; initialHome?: string | null;
session?: string | null; session?: string | null;
source?: number | null; source?: GitSource | null;
initialPath?: string; initialPath?: string;
/** Called after anything changed files, so a tree elsewhere can re-read. */ /** Called after anything changed files, so a tree elsewhere can re-read. */
onChanged?: () => void; onChanged?: () => void;
@@ -268,6 +318,7 @@ export function FileBrowser({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null); const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null); const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
const [cloning, setCloning] = useState(false);
const [ask, setAsk] = useState<Ask | null>(null); const [ask, setAsk] = useState<Ask | null>(null);
const [askAll, setAskAll] = useState(false); const [askAll, setAskAll] = useState(false);
const [progress, setProgress] = useState<{ const [progress, setProgress] = useState<{
@@ -278,6 +329,16 @@ export function FileBrowser({
bytes: number; bytes: number;
} | null>(null); } | null>(null);
const [lanes, setLanes] = useState<Lane[]>([]); const [lanes, setLanes] = useState<Lane[]>([]);
const { state: auth } = useAuth();
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
try {
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
} catch {
return new Set(["me"]);
}
});
const [clip, setClip] = useState<Clip | null>(clipboard); const [clip, setClip] = useState<Clip | null>(clipboard);
const [dropping, setDropping] = useState(false); const [dropping, setDropping] = useState(false);
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null); const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
@@ -314,6 +375,22 @@ export function FileBrowser({
}, []); }, []);
// Another user's home (an admin's view) is read-only. // Another user's home (an admin's view) is read-only.
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly; const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
// Clone into the open folder: the absolute path of a workspace's own folder
// or of the user's own home (never a session worktree, an extra folder or
// another user's home); the server checks the folder again.
const cloneRoot = isHomePlace(target)
? target.home === "me"
? (homes.find((h) => h.id === "me")?.dir ?? null)
: null
: typeof target === "number"
? (lanes.find((l) => l.id === target)?.cwd ?? null)
: null;
const cloneParent =
cloneRoot && view === "files"
? path
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
: cloneRoot
: null;
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */ /** Go to `p` in workspace `tgt`, remembering where we were for Back. */
const go = (p: string, tgt: FsPlace = target) => { const go = (p: string, tgt: FsPlace = target) => {
@@ -542,32 +619,95 @@ export function FileBrowser({
} }
setBusy(true); setBusy(true);
let sent = 0; let sent = 0;
let done = 0;
let saved = 0; let saved = 0;
let forAll: FsConflict | null = null; let forAll: FsConflict | null = null;
try { // false once the server says a batch cannot be written here (an old helper).
for (let i = 0; i < files.length; i++) { let batchable = true;
const { file, rel } = files[i]!; const show = (name: string, extra = 0) =>
const dest = join(dir, rel); setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
setProgress({ name: rel, done: i, total: files.length, sent, bytes }); /** One file; false = the user cancelled. */
const send = (c: FsConflict) => const one = async ({ file, rel }: { file: File; rel: string }) => {
api.files.upload(target, dest, file, c, (n) => const dest = join(dir, rel);
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes }) show(rel);
); const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
try { try {
const r = await send(forAll ?? "fail").catch(async (e) => { const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e; if (errCode(e) !== "EEXISTS") throw e;
const a = await askConflict([rel], files.length > 1); const a = await askConflict([rel], files.length > 1);
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" }); if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
if (a.all) forAll = a.choice; if (a.all) forAll = a.choice;
return send(a.choice); return send(a.choice);
}); });
if (!r.skipped) saved++; if (!r.skipped) saved++;
} catch (e) { } catch (e) {
if (errCode(e) === "ECANCEL") break; if (errCode(e) === "ECANCEL") return false;
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) })); toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
}
sent += file.size;
} }
sent += file.size;
done++;
return true;
};
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
const pack = async (b: { file: File; rel: string }[]) => {
const label = `${b[0]!.rel} (+${b.length - 1})`;
const size = b.reduce((n, f) => n + f.file.size, 0);
show(label);
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
const choose = async (taken: string[]) => {
if (forAll) return forAll;
if (!taken.length) return "fail" as const;
const a = await askConflict(taken, files.length > 1);
if (a.all && a.choice) forAll = a.choice;
return a.choice;
};
try {
const c = await api.files.uploadCheck(
target,
dir,
b.map((f) => f.rel)
);
if (!c.batch) {
batchable = false;
return "fallback";
}
let policy = await choose(c.taken);
if (!policy) return "cancel";
const send = (p: FsConflict) =>
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
const r = await send(policy).catch(async (e) => {
// Taken meanwhile: ask about those, then once more.
if (errCode(e) !== "EEXISTS") throw e;
policy = await choose((e as { names?: string[] }).names ?? []);
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
return send(policy);
});
saved += r.saved;
} catch (e) {
const code = errCode(e);
if (code === "ECANCEL") return "cancel";
// Refused before a byte was written (a bad name, an old helper…):
// each file then gets its own answer.
if (code === "EUPGRADE") batchable = false;
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
return "fallback";
}
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
}
sent += size;
done += b.length;
return "ok";
};
try {
const { packs, singles } = packUploads(files);
let go = true;
for (const b of packs) {
const r = batchable ? await pack(b) : "fallback";
if (r === "cancel") go = false;
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
if (!go) break;
}
for (const f of singles) if (!go || !(await one(f))) break;
} finally { } finally {
setProgress(null); setProgress(null);
setBusy(false); setBusy(false);
@@ -633,7 +773,7 @@ export function FileBrowser({
writePref(VIEW_KEY, l); writePref(VIEW_KEY, l);
}; };
const onKey = (e: ReactKeyboardEvent) => { const onKey = (e: ReactKeyboardEvent) => {
if (renaming || creating || ask) return; if (renaming || creating || ask || cloning) return;
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
// Another user's home is read-only: no key that writes. // Another user's home is read-only: no key that writes.
@@ -772,6 +912,52 @@ export function FileBrowser({
onCancel={() => setRenaming(null)} onCancel={() => setRenaming(null)}
/> />
) : null; ) : null;
// Each workspace drops down under its owner: the caller's own under My
// home, another user's under theirs (`owner_name` = that home's id). An
// owner whose home this viewer may not open (an analyst's view of other
// users) still gets a row, one that only groups. A workspace with no owner
// stays in the flat Workspaces list. Which rows are open is per browser.
const { lanesOfHome, userRows } = useMemo(() => {
const ids = new Set(homes.map((h) => h.id));
const map = new Map<string, Lane[]>();
const extra = new Map<string, UserRow>();
for (const l of lanes) {
const home =
myId != null && l.owner_id === myId && ids.has("me")
? "me"
: l.owner_name && l.owner_name !== "me"
? l.owner_name
: null;
if (!home) continue;
map.set(home, [...(map.get(home) ?? []), l]);
if (!ids.has(home) && !extra.has(home)) {
extra.set(home, {
id: home,
label: l.owner_display_name || home,
readOnly: true,
noHome: true,
});
}
}
const rows: UserRow[] = [
...homes.filter((h) => h.id !== "me"),
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
];
return { lanesOfHome: map, userRows: rows };
}, [homes, lanes, myId]);
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
// A home holding the open workspace shows it, whatever was stored.
const homeIsOpen = (id: string, own: Lane[]) =>
openHomes.has(id) ||
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
const toggleHome = (id: string, own: Lane[]) => {
const next = new Set(openHomes);
if (homeIsOpen(id, own)) next.delete(id);
else next.add(id);
setOpenHomes(next);
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
};
const placeClass = (active: boolean) => const placeClass = (active: boolean) =>
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${ `flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2" active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
@@ -784,67 +970,121 @@ export function FileBrowser({
onClick={() => setMenu(null)} onClick={() => setMenu(null)}
data-testid="file-explorer" data-testid="file-explorer"
> >
{cloneParent && (
<CloneHereDialog
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
onReopen={() => setCloning(true)}
onCloned={(lane, at) => {
setCloning(false);
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
changed();
}}
/>
)}
{/* ── Places ── */} {/* ── Places ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex"> <aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}> <nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
{homes.some((h) => h.id === "me") && ( {(() => {
<div className="pt-3"> const laneRow = (l: Lane, nested: boolean) => (
<button <button
key={l.id}
type="button" type="button"
onClick={() => go("", { home: "me" })} title={l.cwd}
onContextMenu={(e) => placeCtx.open(e, { home: "me" })} onClick={() => go("", l.id)}
className={placeClass( onContextMenu={(e) => placeCtx.open(e, { lane: l })}
view === "files" && isHomePlace(target) && target.home === "me" className={`${placeClass(
)} view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)}${nested ? " pl-7" : ""}`}
> >
<House className="h-4 w-4 shrink-0" /> <FolderGit2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t("fx.myHome")}</span> <span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
</button> </button>
</div> );
)} // A home row with a chevron: its user's workspaces drop down under it.
{homes.some((h) => h.id !== "me") && ( const homeRow = (h: UserRow) => {
<> const own = lanesOfHome.get(h.id) ?? [];
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted"> const open = own.length > 0 && homeIsOpen(h.id, own);
{t("fx.userHomes")} const mine = h.id === "me";
</p> // No home to open: the name drops the list down too.
{homes const browse = !h.noHome;
.filter((h) => h.id !== "me") return (
.map((h) => ( <div key={h.id} data-testid={`fx-home-${h.id}`}>
<button <div className="flex items-center">
key={h.id} <button
type="button" type="button"
title={t("fx.readOnlyHome")} aria-label={t("fx.toggleWorkspaces", {
onClick={() => go("", { home: h.id })} name: mine ? t("fx.myHome") : h.label,
onContextMenu={(e) => placeCtx.open(e, { home: h.id })} })}
className={placeClass( aria-expanded={open}
view === "files" && isHomePlace(target) && target.home === h.id disabled={!own.length}
)} onClick={() => toggleHome(h.id, own)}
> className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
<UserRound className="h-4 w-4 shrink-0" /> >
<span className="truncate">{h.label}</span> {open ? (
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" /> <ChevronDown className="h-3.5 w-3.5" />
</button> ) : (
))} <ChevronRight className="h-3.5 w-3.5" />
</> )}
)} </button>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted"> <button
{t("fx.places")} type="button"
</p> title={mine || !browse ? undefined : t("fx.readOnlyHome")}
{lanes.map((l) => ( aria-expanded={browse ? undefined : open}
<button onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
key={l.id} onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
type="button" className={placeClass(
title={l.cwd} browse && view === "files" && isHomePlace(target) && target.home === h.id
onClick={() => go("", l.id)} )}
onContextMenu={(e) => placeCtx.open(e, { lane: l })} >
className={placeClass( {mine ? (
view === "files" && !isHomePlace(target) && laneOf(target) === l.id <House className="h-4 w-4 shrink-0" />
)} ) : (
> <UserRound className="h-4 w-4 shrink-0" />
<FolderGit2 className="h-4 w-4 shrink-0" /> )}
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span> <span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
</button> {own.length > 0 && (
))} <span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{own.length}
</span>
)}
{!mine && browse && (
<Eye
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
/>
)}
</button>
</div>
{open && own.map((l) => laneRow(l, true))}
</div>
);
};
const me = homes.find((h) => h.id === "me");
const others = userRows;
return (
<>
{me && <div className="pt-3">{homeRow(me)}</div>}
{others.length > 0 && (
<>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
</p>
{others.map(homeRow)}
</>
)}
{looseLanes.length > 0 && (
<>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("fx.places")}
</p>
{looseLanes.map((l) => laneRow(l, false))}
</>
)}
</>
);
})()}
</nav> </nav>
<div className="border-t border-border p-2" data-testid="fx-trashes"> <div className="border-t border-border p-2" data-testid="fx-trashes">
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}> <button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
@@ -1018,6 +1258,17 @@ export function FileBrowser({
> >
{t("fx.newFile")} {t("fx.newFile")}
</DropItem> </DropItem>
{cloneParent && (
<DropItem
icon={<FolderGit2 className="h-4 w-4" />}
onClick={() => {
setMenu(null);
setCloning(true);
}}
>
{t("fx.cloneRepo")}
</DropItem>
)}
</DropButton> </DropButton>
<DropButton <DropButton
label={t("fx.upload")} label={t("fx.upload")}
@@ -1449,6 +1700,11 @@ export function FileBrowser({
{t("fx.newFolder")} {t("fx.newFolder")}
</MenuItem> </MenuItem>
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem> <MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
{cloneParent && (
<MenuItem onSelect={close(() => setCloning(true))}>
{t("fx.cloneRepo")}
</MenuItem>
)}
<MenuSeparator /> <MenuSeparator />
<MenuItem onSelect={close(() => fileInput.current?.click())}> <MenuItem onSelect={close(() => fileInput.current?.click())}>
{t("fx.uploadFiles")} {t("fx.uploadFiles")}
@@ -1627,7 +1883,7 @@ export function FileExplorer({
onClose: () => void; onClose: () => void;
laneId: number; laneId: number;
session?: string | null; session?: string | null;
source?: number | null; source?: GitSource | null;
initialPath?: string; initialPath?: string;
onChanged?: () => void; onChanged?: () => void;
}) { }) {
@@ -1839,7 +2095,7 @@ function InlineName({
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { onKeyDown={(e) => {
e.stopPropagation(); e.stopPropagation();
if (e.key === "Enter") onDone(); if (isSubmitEnter(e)) onDone();
if (e.key === "Escape") onCancel(); if (e.key === "Escape") onCancel();
}} }}
onBlur={onDone} onBlur={onDone}
@@ -103,9 +103,10 @@ import {
Trash2, Trash2,
UnfoldVertical, UnfoldVertical,
} from "lucide-react"; } from "lucide-react";
import { api, gitTarget } from "../../lib/api"; import { api, gitTarget, type GitSource } from "../../lib/api";
import { usePermissions } from "../AuthGate"; import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste"; import { pasteToLane } from "../../lib/panePaste";
import { readMarkdownPreview } from "../../lib/markdownView";
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm"; import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
@@ -826,7 +827,7 @@ export function LaneChangesPanel({
session?: string; session?: string;
/** Show and act on that extra folder's repo instead (`lane_sources` id); /** Show and act on that extra folder's repo instead (`lane_sources` id);
* wins over `session`, and is keyed on like it. */ * wins over `session`, and is keyed on like it. */
source?: number; source?: GitSource;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */ /** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void; onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a /** The panel has the whole width: the file tree becomes a sidebar and a
@@ -873,9 +874,10 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified"); const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]); useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false); const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file. // A whole markdown file shown rendered instead of as source; per file, each
const [mdPreview, setMdPreview] = useState(false); // one opening as Settings → Appearance says (`lib/markdownView.ts`).
useEffect(() => setMdPreview(false), [openFile]); const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null); const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones) // 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 // has no changes, review or history — only its files, which Files reads
@@ -1061,6 +1063,9 @@ export function LaneChangesPanel({
const [historyLoading, setHistoryLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (mode !== "history") return; if (mode !== "history") return;
// A reply for the previous ref/file/page lands after the next request
// started: drop it, or it overwrites the list the user is looking at.
let live = true;
setHistoryLoading(true); setHistoryLoading(true);
void api.lanes void api.lanes
.gitLog(target, { .gitLog(target, {
@@ -1068,16 +1073,23 @@ export function LaneChangesPanel({
...(historyRef ? { ref: historyRef } : {}), ...(historyRef ? { ref: historyRef } : {}),
...(historyFile ? { file: historyFile } : {}), ...(historyFile ? { file: historyFile } : {}),
}) })
.then(forLane(laneId, (r) => setCommits(r.commits))) .then(forLane(laneId, (r) => live && setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err))) .catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
.finally(() => laneNow.current === laneId && setHistoryLoading(false)); .finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
return () => {
live = false;
};
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]); }, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
useEffect(() => { useEffect(() => {
if (mode !== "history") return; if (mode !== "history") return;
let live = true;
void api.lanes void api.lanes
.gitBranches(target) .gitBranches(target)
.then(forLane(laneId, (r) => setHistoryBranches(r.branches))) .then(forLane(laneId, (r) => live && setHistoryBranches(r.branches)))
.catch(() => setHistoryBranches([])); .catch(() => live && setHistoryBranches([]));
return () => {
live = false;
};
}, [mode, laneId, reread]); }, [mode, laneId, reread]);
// Re-run when a lane switch empties the cache too: the full-width sidebar // Re-run when a lane switch empties the cache too: the full-width sidebar
@@ -1171,24 +1183,33 @@ export function LaneChangesPanel({
// own fetch — and only while the viewer is actually showing it. // own fetch — and only while the viewer is actually showing it.
useEffect(() => { useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile) return; if (mode !== "file" || !(whole || editable) || !openFile) return;
// Opening file B before file A's read came back must not show (and let the
// editor save) A's text under B's header.
let live = true;
void api.lanes void api.lanes
.gitFile(target, openFile, range === "commit" && sha ? sha : "work") .gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then( .then(
forLane(laneId, (r) => forLane(
setText( laneId,
r.available (r) =>
? { live &&
path: r.path, setText(
lines: r.lines, r.available
truncated: r.truncated, ? {
binary: r.binary, path: r.path,
sha: r.sha, lines: r.lines,
} truncated: r.truncated,
: null binary: r.binary,
) sha: r.sha,
}
: null
)
) )
) )
.catch((err) => setError(err instanceof Error ? err.message : String(err))); .catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [mode, whole, editable, openFile, laneId, range, sha]); }, [mode, whole, editable, openFile, laneId, range, sha]);
// The editor's original side, read beside the working file. A file that is // The editor's original side, read beside the working file. A file that is
// not there (new since) diffs against nothing: every line is added. // not there (new since) diffs against nothing: every line is added.
@@ -1196,10 +1217,17 @@ export function LaneChangesPanel({
useEffect(() => { useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return; if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD"; const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
let live = true;
void api.lanes.gitFile(target, openFile, rev).then( void api.lanes.gitFile(target, openFile, rev).then(
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")), forLane(
forLane(laneId, () => setBaseText("")) laneId,
(r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")
),
forLane(laneId, () => live && setBaseText(""))
); );
return () => {
live = false;
};
}, [mode, whole, editable, openFile, laneId, range, mergeBase]); }, [mode, whole, editable, openFile, laneId, range, mergeBase]);
useEffect(() => { useEffect(() => {
@@ -1422,6 +1450,27 @@ export function LaneChangesPanel({
} }
}; };
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
}),
confirmLabel: t("changes.discardAll"),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
);
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body. // resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async ( const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort", action: "resolve" | "merge-commit" | "merge-abort",
@@ -1728,7 +1777,12 @@ export function LaneChangesPanel({
{commits?.[0]?.sha === commitCtx.menu.item.sha && ( {commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem <MenuItem
danger danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1} // Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit" testId="lane-changes-menu-undo-commit"
onSelect={pick( onSelect={pick(
commitCtx.close, commitCtx.close,
@@ -1988,6 +2042,8 @@ export function LaneChangesPanel({
}); });
const dirty = draft !== null && draft !== text?.lines.join("\n"); const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown file.
const mdShown = mdPreview && whole && isMarkdown(openFile);
dirtyRef.current = dirty; dirtyRef.current = dirty;
useUnsavedGuard(dirty); useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange); const dirtyChangeRef = useRef(onDirtyChange);
@@ -2751,6 +2807,17 @@ export function LaneChangesPanel({
> >
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")} {t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
</button> </button>
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
</button>
</div> </div>
</div> </div>
)} )}
@@ -2990,7 +3057,7 @@ export function LaneChangesPanel({
range === "commit" && sha ? sha : "work" range === "commit" && sha ? sha : "work"
)} )}
/> />
) : !mdPreview && ) : !mdShown &&
(whole || editable) && (whole || editable) &&
(draft !== null || (editable && text === null)) ? ( (draft !== null || (editable && text === null)) ? (
// The editor, once both texts are in: not the diff first. // The editor, once both texts are in: not the diff first.
@@ -3046,7 +3113,7 @@ export function LaneChangesPanel({
</Suspense> </Suspense>
) )
) : whole ? ( ) : whole ? (
text && mdPreview ? ( text && mdShown ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4"> <div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} /> <MarkdownPreview text={text.lines.join("\n")} />
</div> </div>
@@ -15,7 +15,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { api, gitTarget } from "../../lib/api"; import { api, gitTarget, type GitSource } from "../../lib/api";
import { import {
applyChoices, applyChoices,
parseConflicts, parseConflicts,
@@ -47,7 +47,7 @@ export function LaneConflictView({
/** Work in that session's own worktree (see `gitTarget`). */ /** Work in that session's own worktree (see `gitTarget`). */
session?: string; session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */ /** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number; source?: GitSource;
path: string; path: string;
/** The file left the conflict (resolved, or a whole side taken). */ /** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void; onResolved: () => void;
+2 -2
View File
@@ -38,7 +38,7 @@ import {
Trash2, Trash2,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api"; import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
@@ -98,7 +98,7 @@ export function LaneGitBar({
/** Work in that session's own worktree (see `gitTarget`). */ /** Work in that session's own worktree (see `gitTarget`). */
session?: string; session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */ /** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number; source?: GitSource;
git: LaneGitFacts; git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so /** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */ * the facts and the buttons that act on them read as one row. */
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react"; import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar"; import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget } from "../../lib/api"; import { api, gitTarget, type GitSource } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame"; import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types"; import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
laneId: number, laneId: number,
/** Read that session's own worktree instead (`?session=`). */ /** Read that session's own worktree instead (`?session=`). */
session?: string, session?: string,
/** Read that extra folder's repo instead (`?source=`). */ /** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
source?: number source?: GitSource
): [LaneGitFacts | null, () => void] { ): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null); const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on // Bumped by the git action bar: a commit or a pull changes every fact on
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
}: { }: {
laneId: number; laneId: number;
session?: string; session?: string;
source?: number; source?: GitSource;
/** Opens the Changes panel from the file list's "… more files". */ /** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void; onShowAll?: () => void;
}) { }) {
@@ -349,7 +349,7 @@ export function LaneGitHeader({
/** Work in that session's own worktree (see `gitTarget`). */ /** Work in that session's own worktree (see `gitTarget`). */
session?: string; session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */ /** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number; source?: GitSource;
git: LaneGitFacts | null; git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */ /** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void; onChanged: () => void;
@@ -9,8 +9,9 @@
* first, and a session some tab already holds opens THAT tab instead of a * first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed * second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while * message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude and Codex can resume a session by id — * it is live the composer is off. Only Claude, Codex and a catalog tool with
* any other tool's session offers a new session from the workspace home * `resumeById` (omp) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork * instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder * (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the * come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList"; import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton"; import { SessionResumeButton } from "./SessionResumeButton";
import { useAiTools } from "../../hooks/useAiTools";
/** A message for a session that a tab already holds goes into THAT tab as a /** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation); * paste, once its terminal is on screen (it mounts after the navigation);
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
} }
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */ /** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
// Before the catalog loads: the two hand-written launchers resume by id.
const RESUMABLE = new Set(["claude", "codex"]); const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({ export function SessionReplay({
@@ -65,6 +68,7 @@ export function SessionReplay({
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
const { byId } = useAiTools();
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]); const [images, setImages] = useState<PromptImage[]>([]);
@@ -88,9 +92,9 @@ export function SessionReplay({
}, [sessionId]); }, [sessionId]);
const tool = session?.tool || "claude"; const tool = session?.tool || "claude";
const resumable = RESUMABLE.has(tool); const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId); const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images); const text = promptWithImages(prompt, images, tool === "claude");
const send = async () => { const send = async () => {
if (!text || busy || uploading || livePane || !resumable) return; if (!text || busy || uploading || livePane || !resumable) return;
@@ -1,7 +1,8 @@
/** /**
* @file SessionResumeButton.tsx * @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume and Fork for a Claude * @description The transcript page's explicit Resume and Fork for a Claude
* session inside a workspace. Opening a session never resumes it; this is the * session inside a workspace (Resume only for a catalog tool that resumes by
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
* one place that does, behind a warning, because a resume re-reads the whole * one place that does, behind a warning, because a resume re-reads the whole
* transcript into context. Fork resumes it into a NEW session id * transcript into context. Fork resumes it into a NEW session id
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the * (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession"; import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList"; import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate"; import { usePermissions } from "../AuthGate";
import { useAiTools } from "../../hooks/useAiTools";
/** A path without its trailing separators — `/` or a Windows `\`. */ /** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, ""); const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -54,6 +56,7 @@ export function SessionResumeButton({
const navigate = useNavigate(); const navigate = useNavigate();
const { lanes } = useLanes(); const { lanes } = useLanes();
const { canEditLane } = usePermissions(); const { canEditLane } = usePermissions();
const { byId } = useAiTools();
const [runs, setRuns] = useState<RunHandle[] | null>(null); const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null); const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -70,12 +73,13 @@ export function SessionResumeButton({
}; };
}, [session.id]); }, [session.id]);
// Claude only: a Codex session id is not what // Claude, or a catalog tool that resumes by id (no launcher of its own): a
// `codex resume` takes for every kind of thread. // Codex session id is not what `codex resume` takes for every kind of thread.
const tool = session.tool || "claude"; const tool = session.tool || "claude";
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
// The workspace that owns it, else the one whose folder holds it. // The workspace that owns it, else the one whose folder holds it.
const lane = const lane =
tool !== "claude" || !session.cwd (tool !== "claude" && !catalogResume) || !session.cwd
? null ? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ?? : (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes)); owningLane(session.cwd, tool, lanes));
@@ -140,7 +144,7 @@ export function SessionResumeButton({
{t("sessions.resume")} {t("sessions.resume")}
</button> </button>
)} )}
{resumable && ( {resumable && tool === "claude" && (
<button <button
type="button" type="button"
onClick={() => setConfirming("fork")} onClick={() => setConfirming("fork")}
+28 -16
View File
@@ -11,7 +11,7 @@
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the * shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy * proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval, * (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it * a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot, * owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in * **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, * its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
@@ -20,7 +20,6 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { Checkbox } from "../Checkbox";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -34,6 +33,7 @@ import {
MoreHorizontal, MoreHorizontal,
ArrowUp, ArrowUp,
SquareTerminal, SquareTerminal,
Zap,
} from "lucide-react"; } from "lucide-react";
import { import {
api, api,
@@ -76,7 +76,7 @@ export {
} from "./startSession"; } from "./startSession";
import { startInFreeTab } from "./startSession"; import { startInFreeTab } from "./startSession";
import { WorkspaceContext } from "./WorkspaceContext"; import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults"; import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */ /** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30; const RECENT_PAGE = 30;
@@ -165,12 +165,14 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a // Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never. // model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag; const hasModel = claude || codex || !!entry?.modelFlag;
// The account's own default (first-run setup) — never bypass. // The account's own defaults (first-run setup, Settings → AI tools →
// Session defaults); a pick here is for this session only.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode); const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write"); const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
const [approval, setApproval] = useState<CodexApproval>("on-request"); const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
// Per session, never remembered. // Off for every tool but Codex, which starts from the account's default.
const [bypass, setBypass] = useState(false); const [bypass, setBypass] = useState(false);
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
const canBypass = !claude && !!entry?.bypass; const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>(""); const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex; const hasEffort = claude || codex;
@@ -254,7 +256,7 @@ function HomeBody({
} }
}; };
const text = promptWithImages(prompt, images); const text = promptWithImages(prompt, images, claude);
const start = async () => { const start = async () => {
// Enter reaches here past the disabled button: the same guards again. // Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false. // A ref, not only the state: two Enters in one tick both read `starting` false.
@@ -607,15 +609,25 @@ function HomeBody({
</div> </div>
)} )}
{canBypass && ( {canBypass && (
<span className="px-2"> // A chip like the selects beside it: quiet when off, red when on.
<Checkbox <button
tone="danger" type="button"
checked={bypass} aria-pressed={bypass}
onChange={setBypass} data-testid="workspace-home-bypass"
label={t("run:fields.bypass")} title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
labelClassName="text-xs text-status-danger" onClick={() => setBypass(!bypass)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
bypass
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Zap
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
aria-hidden
/> />
</span> {t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
</button>
)} )}
</div> </div>
{danger && ( {danger && (
@@ -3,7 +3,9 @@
* the pointer (right-click) or under the row's `⋯` button. Every item is an * the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Rename (the row's inline field), New workspace here (the * existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a * Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides * managed lane — plus Move to group (the sidebar's own groups,
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
* server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position, * what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`. * Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -11,7 +13,9 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { Check } from "lucide-react";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove"; export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
y, y,
onClose, onClose,
onAction, onAction,
groups = [],
groupId = null,
onGroup,
}: { }: {
lane: Lane; lane: Lane;
x: number; x: number;
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
onClose: () => void; onClose: () => void;
/** Items the tree itself carries out (rename field, modals). */ /** Items the tree itself carries out (rename field, modals). */
onAction: (action: WorkspaceMenuAction) => void; onAction: (action: WorkspaceMenuAction) => void;
/** The sidebar's groups and the one this workspace is in. */
groups?: WorkspaceGroup[];
groupId?: string | null;
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
onGroup?: (target: string | null | "new") => void;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
return ( return (
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
> >
{t("menu.copyPath")} {t("menu.copyPath")}
</MenuItem> </MenuItem>
{onGroup && (
<>
<MenuSeparator />
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
{t("menu.moveToGroup")}
</div>
{groups.map((g) => (
<MenuItem
key={g.id}
testId={`workspace-move-group-${g.id}`}
disabled={g.id === groupId}
onSelect={() => onGroup(g.id)}
>
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
<span className="truncate">{g.name}</span>
</MenuItem>
))}
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
<span className="w-3 shrink-0" />
{t("menu.newGroupWith")}
</MenuItem>
{groupId && (
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
<span className="w-3 shrink-0" />
{t("menu.removeFromGroup")}
</MenuItem>
)}
</>
)}
<MenuSeparator /> <MenuSeparator />
{lane.kind === "managed" && ( {lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}> <MenuItem danger onSelect={() => onAction("reset")}>
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }), tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"), tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }), tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }), tool("goose", "Goose", { enabled: false }),
], ],
}; };
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
) )
); );
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined(); expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled()); await waitFor(() => expect(onAdded).toHaveBeenCalled());
}); });
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
created: true,
path: "/home/an/code/big",
});
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/big.git");
await user.click(screen.getByTestId("add-lane-clone-partial"));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
);
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => { it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone) vi.mocked(api.lanes.clone)
.mockRejectedValueOnce( .mockRejectedValueOnce(
@@ -0,0 +1,79 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
detail: null,
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -1,7 +1,10 @@
/** /**
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as * @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 * an extra folder, a host that wants a login opens the login row, and Retry
* re-runs only the failed URLs with that login. * re-runs only the failed URLs with that login. An SSH link while the
* account has no key is never sent — the user is pointed at Settings → Git —
* and an HTTPS host with no saved login is flagged before Clone. The
* partial-clone box adds `partial: true` to every clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -11,16 +14,20 @@ import userEvent from "@testing-library/user-event";
import "../../../i18n"; import "../../../i18n";
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneFolder: vi.fn() } }, api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
})); }));
import { api, type LaneSourcesView } from "../../../lib/api"; import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm } from "../CloneSourcesForm"; import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string }; const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => { describe("CloneSourcesForm", () => {
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset()); beforeEach(() => {
vi.mocked(api.lanes.cloneFolder).mockReset();
// Unknown identity: nothing is checked up front.
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
});
it("clones each URL, asks for a login where needed, and retries only that one", async () => { it("clones each URL, asks for a login where needed, and retries only that one", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -53,9 +60,61 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2)); await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, { expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
url: "https://private.test/b.git", url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true }, credentials: { username: "dev", password: "tok", remember: true },
}); });
expect(screen.getByTestId("workspace-clone-input")).toHaveValue(""); expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
}); });
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
await user.click(screen.getByTestId("workspace-clone-partial"));
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
url: "https://h/big.git",
partial: true,
})
);
});
it("tells an SSH link from an HTTPS one", () => {
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
expect(cloneTarget("not a url")).toBeNull();
});
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
const user = userEvent.setup();
vi.mocked(api.gitIdentity.get).mockResolvedValue({
ssh: null,
sshKeys: [],
https: [],
} as unknown as GitIdentity);
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
await user.type(
screen.getByTestId("workspace-clone-input"),
"git@github.com:o/r.git\nhttps://h.test/a.git"
);
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
});
}); });
@@ -7,7 +7,9 @@
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable * 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 * one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and * purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty. * shows a Trash that could not be read as an error, never as empty; nests a
* user's workspaces under their home in the sidebar (under a fold-only row
* when the viewer may not open that home).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -21,11 +23,14 @@ import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) })); vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane), gitTarget: (lane: number, session?: string | null, source?: number | null) =>
source ? { lane, source } : session ? { lane, session } : lane,
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p, isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
api: { api: {
lanes: { lanes: {
list: vi.fn(), list: vi.fn(),
clone: vi.fn(),
cloneProgress: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`, gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
}, },
files: { files: {
@@ -43,6 +48,8 @@ vi.mock("../../../lib/api", () => ({
restore: vi.fn(), restore: vi.fn(),
purge: vi.fn(), purge: vi.fn(),
upload: vi.fn(), upload: vi.fn(),
uploadBatch: vi.fn(),
uploadCheck: vi.fn(),
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`, zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
}, },
}, },
@@ -194,6 +201,41 @@ describe("FileExplorer", () => {
); );
}); });
it("sends many small files as one batch, asking once about every taken name", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Replace" }));
await waitFor(() =>
expect(api.files.uploadBatch).toHaveBeenCalledWith(
1,
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.any(Function)
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
expect(api.files.upload).not.toHaveBeenCalled();
});
it("falls back to one request per file when the server cannot take a batch", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => { it("refuses a batch over 500 MB before sending anything", async () => {
open(); open();
await screen.findByText("a.txt"); await screen.findByText("a.txt");
@@ -431,4 +473,201 @@ describe("FileExplorer", () => {
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" }); fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
expect(api.files.remove).not.toHaveBeenCalled(); expect(api.files.remove).not.toHaveBeenCalled();
}); });
it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => {
localStorage.removeItem("agenthub.files.homesOpen");
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{ id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" },
],
counts: {},
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
const places = screen.getByRole("navigation", { name: "Workspaces" });
expect(within(places).queryByRole("button", { name: "two" })).toBeNull();
expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument();
await user.click(toggle);
await user.click(within(places).getByRole("button", { name: "two" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob");
});
it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => {
localStorage.removeItem("agenthub.files.homesOpen");
vi.mocked(api.files.homes).mockResolvedValue({
homes: [{ id: "me", label: "An", readOnly: false }],
});
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{
id: 3,
title: "three",
cwd: "/w/three",
owner_id: 9,
owner_name: "cara",
owner_display_name: "Cara",
},
],
counts: {},
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const places = screen.getByRole("navigation", { name: "Workspaces" });
expect(within(places).getByText("Users")).toBeInTheDocument();
expect(within(places).queryByRole("button", { name: "three" })).toBeNull();
await user.click(within(places).getByRole("button", { name: /^Cara/ }));
expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, "");
expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument();
});
});
describe("FileExplorer — New → Clone repository", () => {
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "New" }));
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
return screen.getByTestId("fx-clone-dialog");
};
it("clones into the open folder of the workspace and says where", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
created: true,
path: "/w/one/src/repo",
} as never);
const user = userEvent.setup();
open();
fireEvent.doubleClick(await screen.findByText("src"));
await screen.findByText("x.js");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith({
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("shows progress and lets the page go on while the clone runs", async () => {
let finish: (v: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
state: "running",
position: null,
running: 1,
waiting: 0,
limit: 2,
phase: "receiving",
label: null,
percent: 45,
detail: "450/1000",
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
const chip = await screen.findByTestId("fx-clone-chip");
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
await user.click(chip);
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
finish({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
});
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
});
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
let fail: (e: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await user.keyboard("{Escape}");
fail(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const again = await openClone(user);
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
"https://git.example/o/r.git"
);
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
)
.mockResolvedValueOnce({
lane: { id: 9, title: "r", cwd: "/w/one/r" },
created: true,
path: "/w/one/r",
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
const login = await within(dialog).findByTestId("fx-clone-login");
await user.type(within(login).getByLabelText("Username"), "ann");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenLastCalledWith(
expect.objectContaining({
parent: "/w/one",
credentials: { username: "ann", password: "tok", remember: true },
})
)
);
});
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
const dialog = await openClone(user);
const url = within(dialog).getByLabelText("Repository URL");
await user.type(url, "abc{Backspace}{Delete}");
expect(api.files.remove).not.toHaveBeenCalled();
expect(url).toHaveValue("ab");
});
it("is not offered in an extra folder", async () => {
const user = userEvent.setup();
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "New" }));
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
});
}); });
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
const box = within(folder.parentElement!).getByRole("checkbox", { const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib", name: "Tick every file in server/lib",
}); });
expect(box).toBeChecked(); // The default ticks land in an effect after the list renders.
await waitFor(() => expect(box).toBeChecked());
await user.click(box); await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked(); expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder); await user.click(folder);
@@ -514,7 +515,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("Unversioned Files")).toBeInTheDocument(); expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
}); });
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => { it("opens a whole markdown file rendered, as source after Raw, and rendered again after Preview", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
path: "", path: "",
@@ -534,12 +535,13 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar"); const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md")); await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-editor"); expect(await screen.findByRole("heading", { name: "Hello" })).toBeInTheDocument();
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview")); await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one"); expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
}); });
it("shows an image file as the picture, with no edit control", async () => { it("shows an image file as the picture, with no edit control", async () => {
@@ -770,6 +772,24 @@ describe("LaneChangesPanel", () => {
}); });
}); });
it("discards every file at once only after the dialog is confirmed", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
confirm: true,
})
);
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => { it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
@@ -1062,6 +1082,20 @@ describe("LaneChangesPanel", () => {
); );
}); });
it("does not offer Undo for a top commit its upstream already holds", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
await waitFor(() => expect(undo).toBeDisabled());
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
});
it("cherry-picks only from another branch's history", async () => { it("cherry-picks only from another branch's history", async () => {
withParent(); withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts()); vi.mocked(api.lanes.git).mockResolvedValue(facts());
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
// As the Workspace page: `&run=` replaces the home with the session view. // As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() { function HomeUntilRun() {
const [params] = useSearchParams(); const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />; return params.get("run") ? null : (
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
);
} }
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings"; import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
import { markOnboarded, useOnboarding } from "../../lib/userPrefs"; import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
import { import {
DEFAULT_MODES,
readDefaultMode, readDefaultMode,
readDefaultTool, readDefaultTool,
SAFE_MODES,
writeDefaultMode, writeDefaultMode,
writeDefaultTool, writeDefaultTool,
type DefaultMode, type DefaultMode,
@@ -310,7 +310,7 @@ function AgentStep() {
writeDefaultMode(m); writeDefaultMode(m);
setMode(m); setMode(m);
}} }}
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))} options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
/> />
</div> </div>
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p> <p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
@@ -2,7 +2,7 @@
* @file Onboarding.test.tsx * @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's * @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change * preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only * is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only
* for an account that is new to it, writes each pick like Settings does and * for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step. * ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
})); }));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs"; import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults"; import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard"; import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour"; import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools"; import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -98,10 +98,12 @@ describe("account preferences", () => {
} }
}); });
it("never keeps bypass as the starting permission mode", () => { it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions"); localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto"); expect(readDefaultMode()).toBe("bypassPermissions");
writeDefaultMode("bypassPermissions" as never); expect(readDefaultMode({ safe: true })).toBe("auto");
expect(SAFE_MODES).not.toContain("bypassPermissions");
writeDefaultMode("nonsense" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions"); expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan"); writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan"); expect(readDefaultMode()).toBe("plan");
+34 -2
View File
@@ -1,7 +1,10 @@
/** /**
* @file ChatAddMenu.tsx * @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add * @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach — * files (the prompt box's own picker), Choose a file on this computer (a
* browser on the dashboard's own machine only: the OS's own picker,
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
* nothing uploaded), what is already there to attach —
* Project files (a search of the box's folder, folders too: * Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context * `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's, * files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
@@ -21,6 +24,7 @@ import {
ChevronRight, ChevronRight,
FileText, FileText,
FolderOpen, FolderOpen,
HardDrive,
History, History,
Paperclip, Paperclip,
Plug, Plug,
@@ -29,7 +33,14 @@ import {
Settings2, Settings2,
} from "lucide-react"; } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach"; import {
isLocalBrowser,
localAttachment,
projectAttachment,
recentUploads,
type Attachment,
} from "../../lib/promptAttach";
import { toast } from "../../lib/toast";
interface Entry { interface Entry {
name: string; name: string;
@@ -244,6 +255,17 @@ export function ChatAddMenu({
close(); close();
onAttach([item]); onAttach([item]);
}; };
const pickLocal = async () => {
close();
// The dialog opens on the desktop, sometimes behind this window.
toast.info(t("chat.pickLocalOpen"));
try {
const r = await api.lanes.pickPath("file", cwd || undefined);
if (r.path) onAttach([localAttachment(r.path)]);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const manage = (tab: "skills" | "plugins") => { const manage = (tab: "skills" | "plugins") => {
close(); close();
navigate(`/cc-config?tab=${tab}`); navigate(`/cc-config?tab=${tab}`);
@@ -291,6 +313,16 @@ export function ChatAddMenu({
onUpload(); onUpload();
}} }}
/> />
{isLocalBrowser() && (
<Row
icon={<HardDrive className="h-4 w-4" />}
label={t("chat.pickLocal")}
title={t("chat.pickLocalHint")}
testId="chat-add-pick-local"
onHover={() => setSub(null)}
onClick={() => void pickLocal()}
/>
)}
<Row <Row
icon={<FolderOpen className="h-4 w-4" />} icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")} label={t("chat.projectFiles")}
+303 -39
View File
@@ -18,15 +18,41 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useRef, useState, type DragEvent } from "react"; import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Copy, FileText, Loader2, X } from "lucide-react"; import {
ChevronLeft,
ChevronRight,
Copy,
Download,
FileText,
Loader2,
Maximize2,
Minimize2,
Share2,
X,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api"; import { api, gitTarget } from "../../lib/api";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach"; import { dragSkill } from "../../lib/promptAttach";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
import type { TranscriptMessage } from "../../lib/types";
import {
RENDERED,
artifactDocument,
artifactKind,
artifactLang,
artifactVersions,
frameSandbox,
parseCsv,
type ArtifactKind,
} from "../../lib/artifactView";
/** Where a Chat pane's files can be read from. */ /** Where a Chat pane's files can be read from. */
export interface PreviewScope { export interface PreviewScope {
@@ -146,33 +172,96 @@ function useContent(path: string, scope: PreviewScope): Content {
return content; return content;
} }
function Body({ path, scope }: { path: string; scope: PreviewScope }) { /** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
const [head, ...body] = rows;
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<table className="w-full border-collapse text-xs">
{head && (
<thead className="sticky top-0 bg-surface-2">
<tr>
{head.map((h, i) => (
<th
key={i}
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
>
{h}
</th>
))}
</tr>
</thead>
)}
<tbody>
{body.map((r, i) => (
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
{r.map((c, j) => (
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
* scripts (none for an SVG) but never the dashboard's origin. */
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
const { theme } = useTheme();
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
return (
<iframe
data-testid="artifact-frame"
title={title}
srcDoc={doc ?? ""}
sandbox={frameSandbox(kind)}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
/>
);
}
type View = "preview" | "code";
/** A file's first tab: Preview where it renders — Markdown as Settings → Appearance says. */
function defaultView(path: string): View {
const kind = artifactKind(path);
if (!RENDERED.has(kind)) return "code";
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
}
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const loaded = useContent(path, scope); const kind = artifactKind(path);
// An image the route refused (gone, server mode) shows like no preview. // An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null); const [broken, setBroken] = useState<string | null>(null);
const content: Content = const shown: Content =
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded; content.kind === "image" && content.src === broken ? { kind: "none" } : content;
if (content.kind === "loading") { if (shown.kind === "loading") {
return ( return (
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" /> <Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div> </div>
); );
} }
if (content.kind === "image") { if (shown.kind === "image") {
return ( return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4"> <div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img <img
src={content.src} src={shown.src}
alt={baseName(path)} alt={baseName(path)}
onError={() => setBroken(content.src)} onError={() => setBroken(shown.src)}
className="max-h-full max-w-full object-contain" className="max-h-full max-w-full object-contain"
/> />
</div> </div>
); );
} }
if (content.kind === "none") { if (shown.kind === "none") {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted"> <div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" /> <FileText className="h-8 w-8" />
@@ -181,69 +270,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
</div> </div>
); );
} }
return ( const note = shown.truncated && (
<div className="min-h-0 flex-1 overflow-auto"> <p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
{isMarkdown(path) ? (
<MarkdownPreview text={content.text} className="p-4" />
) : (
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
{content.text}
</pre>
)}
{content.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
)}
</div>
); );
if (view === "code" || !RENDERED.has(kind)) {
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
{note}
</div>
);
}
if (kind === "markdown") {
return (
<div className="min-h-0 flex-1 overflow-auto">
<MarkdownPreview text={shown.text} className="p-4" />
{note}
</div>
);
}
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
} }
/** One file: header (name · EXT, Copy path, Close) over its content. */ const iconBtn =
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
/** Saves text (or an image's URL) as a file through the browser. */
function download(name: string, content: Content) {
const a = document.createElement("a");
if (content.kind === "image") a.href = content.src;
else if (content.kind === "text")
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
else return;
a.download = name;
a.click();
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
* versions the conversation made of it, Copy, Download, Share to the
* Artifacts Hub, full screen, Close. */
export function FilePreview({ export function FilePreview({
path, path,
scope, scope,
onClose, onClose,
className = "", className = "",
messages,
}: { }: {
path: string; path: string;
scope: PreviewScope; scope: PreviewScope;
onClose: () => void; onClose: () => void;
className?: string; className?: string;
/** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[];
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
return ( const kind = artifactKind(path);
const loaded = useContent(path, scope);
const [view, setView] = useState<View>(() => defaultView(path));
const [full, setFull] = useState(false);
const [sharing, setSharing] = useState(false);
const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []),
[messages, path]
);
// null: the newest — the file as it is now.
const [picked, setPicked] = useState<number | null>(null);
// Another file opens on its own default tab, at its newest version.
useEffect(() => {
setPicked(null);
setView(defaultView(path));
}, [path]);
const last = Math.max(versions.length - 1, 0);
const at = picked ?? last;
const older = picked !== null && picked < last ? versions[picked] : null;
const content: Content =
older && older.content !== null
? { kind: "text", text: older.content, truncated: false }
: loaded;
const known = (i: number) => i === last || versions[i]?.content != null;
const step = (dir: -1 | 1) => {
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
if (known(i)) return setPicked(i === last ? null : i);
}
};
const share = async () => {
if (sharing) return;
setSharing(true);
try {
const file = slashed(path);
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
await api.artifacts.pinFile({
laneId: scope.laneId,
path: file.slice(base.length + 1),
title: displayName(path),
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
});
} else if (content.kind === "text") {
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
title: displayName(path),
});
} else throw new Error(t("chat.artifact.cannotShare"));
toast.success(t("chat.artifact.shared"));
} catch (err) {
toast.error(err);
} finally {
setSharing(false);
}
};
const panel = (
<section <section
data-testid="chat-file-preview" data-testid="chat-file-preview"
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`} className={
full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
}
> >
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5"> <header className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}> <button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")} {displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</span> </button>
<span className="text-xs text-fg-muted">{extOf(path)}</span> <span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
<div className="ml-auto flex items-center gap-1"> {versions.length > 1 && (
<span
data-testid="artifact-versions"
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
>
<button
type="button"
onClick={() => step(-1)}
disabled={![...Array(at).keys()].some(known)}
aria-label={t("chat.artifact.older")}
title={t("chat.artifact.older")}
className={iconBtn}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="tabular-nums">
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
</span>
<button
type="button"
onClick={() => step(1)}
disabled={at >= last}
aria-label={t("chat.artifact.newer")}
title={t("chat.artifact.newer")}
className={iconBtn}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</span>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
{RENDERED.has(kind) && kind !== "image" && (
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
{(["preview", "code"] as const).map((v) => (
<button
key={v}
type="button"
role="tab"
aria-selected={view === v}
data-testid={`artifact-tab-${v}`}
onClick={() => setView(v)}
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
>
{t(`chat.artifact.${v}`)}
</button>
))}
</div>
)}
<button <button
type="button" type="button"
onClick={() => void copyText(path)} data-testid="artifact-copy"
title={t("chat.outline.copyPath", { path })} onClick={() => void copyText(content.kind === "text" ? content.text : path)}
aria-label={t("chat.outline.copyPath", { path })} title={t("chat.artifact.copy")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary" aria-label={t("chat.artifact.copy")}
className={iconBtn}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>
<button
type="button"
onClick={() => download(displayName(path), content)}
disabled={content.kind !== "text" && content.kind !== "image"}
title={t("chat.artifact.download")}
aria-label={t("chat.artifact.download")}
className={iconBtn}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="artifact-share"
onClick={() => void share()}
disabled={sharing || content.kind === "loading" || content.kind === "none"}
title={t("chat.artifact.share")}
aria-label={t("chat.artifact.share")}
className={iconBtn}
>
{sharing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Share2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
title={t("chat.outline.close")} title={t("chat.outline.close")}
aria-label={t("chat.outline.close")} aria-label={t("chat.outline.close")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary" className={iconBtn}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
</header> </header>
<Body path={path} scope={scope} /> <Body path={path} content={content} view={view} />
</section> </section>
); );
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
} }
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */ /** Claude's Uploads dialog: the files on the left, the picked one on the right. */
@@ -12,7 +12,8 @@
* new lane's own live session for its slot, or an empty pane — never the * new lane's own live session for its slot, or an empty pane — never the
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a * previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
* tab) attaches to that tmux session only; nothing is started or resumed. * tab) attaches to that tmux session only; nothing is started or resumed.
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's * A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) also
* has a Chat view (`PaneChat`): the session's
* conversation laid OVER the terminal, which stays mounted and attached under * conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle * it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
* left of the pane's split buttons); the Chat UI setting (Settings → * left of the pane's split buttons); the Chat UI setting (Settings →
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat"; import { PaneChat } from "./PaneChat";
import { useSessionChat } from "../../lib/chatUi"; import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]);
export interface LaneConsolePaneProps { export interface LaneConsolePaneProps {
lanes: Lane[]; lanes: Lane[];
@@ -218,6 +217,7 @@ export function LaneConsolePane({
wsBaseUrl={window.location.origin.replace(/^http/, "ws")} wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)} autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)} covered={!!(chatable && chat)}
tool={handle.tool}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) — // The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box) // the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of. // rather than leaving a dead terminal the user has to click out of.
+339 -21
View File
@@ -15,7 +15,8 @@
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a * in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
* permission or trust prompt, Codex's approval — is read off the terminal * permission or trust prompt, Codex's approval — is read off the terminal
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons * underneath (`readRunMenu`) and shown in the prompt box's place as buttons
* that press its keys there (`PaneMenuCard`); its "Type something" row opens * that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
* (`PermissionMenuCard`); its "Type something" row opens
* a field in the card, whose text is typed there as the answer * a field in the card, whose text is typed there as the answer
* (`answerRunMenu`). * (`answerRunMenu`).
* It also has Claude Desktop's activity panel (`ChatOutline`: progress, * It also has Claude Desktop's activity panel (`ChatOutline`: progress,
@@ -56,7 +57,17 @@
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react"; import {
ArrowUp,
Check,
ChevronLeft,
ChevronRight,
FileText,
MessageCircleQuestion,
ShieldQuestion,
Square,
SquareCheck,
} from "lucide-react";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView"; import { ConversationView } from "../conversation/ConversationView";
import { import {
@@ -77,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
import { import {
answerRunMenu, answerRunMenu,
chooseRunOption, chooseRunOption,
notePaneFocus,
onPaneOutput,
stepRunMenu,
interruptRun, interruptRun,
readRunBusy,
readRunMenu, readRunMenu,
submitToRun, submitToRun,
type PaneMenu, type PaneMenu,
@@ -89,7 +104,9 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge"; import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format"; import { fmt } from "../../lib/format";
import { agentAwaitingReason } from "../../lib/types"; import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
import { eventBus } from "../../lib/eventBus";
import { throttle } from "../../lib/throttle";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api"; import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [ const MODES: [PermissionMode, string][] = [
@@ -100,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
["bypassPermissions", "Bypass"], ["bypassPermissions", "Bypass"],
]; ];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m); const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
// Pane output re-reads the transcript at most this often: a TUI spinner
// redraws several times a second, and each read walks the transcript file.
const OUTPUT_FETCH_MS = 1000;
/** A tool with no known busy line counts as at work this long after its pane last drew. */
const OUTPUT_BUSY_MS = 2000;
const SIDE_KEY = "agenthub.chat.panelWidth"; const SIDE_KEY = "agenthub.chat.panelWidth";
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100; 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 outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
@@ -117,6 +139,7 @@ const storedSide = () => {
* (LiteLLM's list, 2026-10). Null for another vendor's model. */ * (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. // 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 { export function contextWindow(model: string | null): number | null {
// A Codex/omp chat reports its own window (ContextMeter's `window`).
if (!model?.startsWith("claude-")) return null; if (!model?.startsWith("claude-")) return null;
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model) return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
? 200_000 ? 200_000
@@ -125,7 +148,13 @@ export function contextWindow(model: string | null): number | null {
/** How full the context is: a ring and a percentage, amber then red as /** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */ * Claude Code's auto-compact comes near. */
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) { function ContextMeter({
tokens,
model,
}: {
tokens: number;
model: string | null;
}) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const size = contextWindow(model); const size = contextWindow(model);
if (!size || !tokens) return null; if (!size || !tokens) return null;
@@ -212,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
); );
} }
/** A permission prompt (Claude's tool approval, Codex's command approval,
* the folder-trust question): it offers a Yes and a No. */
export function isPermissionMenu(menu: PaneMenu): boolean {
if (menu.multi || menu.tabs) return false;
const labels = menu.options.map((o) => o.label);
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
}
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
* answers as buttons — each still presses its key in the pane. "No, and
* tell … differently" also takes a note, sent as the next message once the
* CLI is back at its prompt. */
function PermissionMenuCard({
menu,
onChoose,
onDenyNote,
onCancel,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onDenyNote: (index: number, note: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation("run");
const [noting, setNoting] = useState(false);
const [note, setNote] = useState("");
const q = menu.question.split("\n");
const title = menu.detail?.[0] ?? q[0] ?? "";
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
const ask = menu.detail ? menu.question : "";
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
const sendNote = () => {
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
};
return (
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{title}
</p>
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
</div>
</div>
{body && (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
{body}
</pre>
)}
{noting ? (
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
<input
autoFocus
data-testid="pane-chat-permission-note"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
sendNote();
} else if (e.key === "Escape") setNoting(false);
}}
placeholder={t("chat.request.notePlaceholder")}
aria-label={t("chat.request.notePlaceholder")}
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"
onClick={sendNote}
disabled={!note.trim()}
className="btn-secondary text-xs disabled:opacity-40"
>
{t("chat.request.send")}
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{menu.options.map((o, i) => (
<button
key={`${i}-${o.label}`}
type="button"
data-testid="pane-chat-permission-option"
autoFocus={i === yes}
onClick={() => onChoose(i)}
title={o.hint || undefined}
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
>
{bareLabel(o.label)}
</button>
))}
{tell >= 0 && (
<button
type="button"
data-testid="pane-chat-permission-tell"
onClick={() => setNoting(true)}
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{t("chat.request.denyWithNote")}
</button>
)}
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
)}
</div>
);
}
/** The menu the CLI asks with, as buttons: a pick presses its key in the /** 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 — * 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. */ * pressing that row's digit again would type the digit into the CLI's own.
* A permission prompt takes its own layout (`PermissionMenuCard`), any
* other menu the question layout. */
export function PaneMenuCard({ export function PaneMenuCard({
menu, menu,
onChoose, onChoose,
onAnswer, onAnswer,
onCancel, onCancel,
onStep,
onDenyNote,
}: { }: {
menu: PaneMenu; menu: PaneMenu;
onChoose: (index: number) => void; onChoose: (index: number) => void;
onAnswer: (text: string) => void; onAnswer: (text: string) => void;
onCancel: () => void; onCancel: () => void;
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
onStep?: (dir: -1 | 1) => void;
/** A permission prompt's "No, and tell …", with the note to send after it. */
onDenyNote?: (index: number, note: string) => void;
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const [typing, setTyping] = useState(false); const [typing, setTyping] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const submit = () => draft.trim() && onAnswer(draft); const submit = () => draft.trim() && onAnswer(draft);
if (isPermissionMenu(menu))
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
<PermissionMenuCard
menu={menu}
onChoose={onChoose}
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
onCancel={onCancel}
/>
</div>
);
return ( return (
<div <div
data-testid="pane-chat-menu" data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm" className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
> >
{menu.tabs && (
<div
data-testid="pane-chat-menu-tabs"
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
>
{onStep && (
<button
type="button"
onClick={() => onStep(-1)}
aria-label={t("chat.menu.prev")}
title={t("chat.menu.prev")}
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
)}
{menu.tabs.map((tab, i) => (
<span
key={`${i}-${tab.label}`}
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
i === menu.tab
? "border-accent/50 bg-surface-2 text-fg-primary"
: "border-border text-fg-muted"
}`}
>
{tab.done ? (
<Check className="h-3 w-3 text-accent" />
) : (
<Square className="h-3 w-3" />
)}
{tab.label}
</span>
))}
{onStep && (
<button
type="button"
onClick={() => onStep(1)}
aria-label={t("chat.menu.next")}
title={t("chat.menu.next")}
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{menu.question && ( {menu.question && (
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary"> <div className="mb-2 flex items-start gap-2">
{menu.question} <MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
</p> <p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
</div>
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{menu.options.map((o, i) => {menu.options.map((o, i) =>
@@ -338,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
const runId = run.id; const runId = run.id;
const claude = run.tool === "claude"; const claude = run.tool === "claude";
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`). // The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt); const draftKey = sessionId;
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images); const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]); const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [follow, setFollow] = useState(0); const [follow, setFollow] = useState(0);
const [model, setModel] = useState(run.model ?? ""); const [model, setModel] = useState(run.model ?? "");
@@ -403,7 +622,11 @@ export const PaneChat = memo(function PaneChat({
// status filter ignored: check the status here. // status filter ignored: check the status here.
.then((r) => { .then((r) => {
const main = r.agents.filter((a) => a.type === "main"); const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working")); // A fresh start, resume or `/clear` leaves main "working" at an
// empty prompt (SessionStart's flag) until the first prompt.
setWorking(
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
);
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification")); setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
}) })
.catch(() => {}), .catch(() => {}),
@@ -412,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
useEffect(() => { useEffect(() => {
void readWorking(); void readWorking();
}, [readWorking]); }, [readWorking]);
// The terminal underneath drew something: the CLI is at work, so its
// transcript may have grown — read it now, for any tool, hooks or not. A
// spinner redraws all the time, hence the throttle.
useEffect(() => {
if (!sessionId) return;
const poke = throttle(
() =>
eventBus.publish({
type: "transcript_changed",
data: { session_id: sessionId },
} as unknown as WSMessage),
OUTPUT_FETCH_MS
);
const off = onPaneOutput(runId, poke);
return () => {
off();
poke.cancel();
};
}, [runId, sessionId]);
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
// shows, else — a tool whose line we do not know — output in the last
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
// without its opt-in hooks), and arrive late on a dashboard they skip.
const [screenBusy, setScreenBusy] = useState(false);
useEffect(() => {
let last = 0;
const off = onPaneOutput(runId, () => (last = Date.now()));
const read = () => {
const known = readRunBusy(runId, run.tool);
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
};
read();
const timer = window.setInterval(() => {
if (isShown(onScreen)) read();
}, 700);
return () => {
off();
window.clearInterval(timer);
};
}, [runId, run.tool, onScreen]);
useLiveRefresh(readWorking, { useLiveRefresh(readWorking, {
throttleMs: 500, throttleMs: 500,
// While Stop shows, also poll: a missed agent_updated (the hook reached // While Stop shows, also poll: a missed agent_updated (the hook reached
@@ -440,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
}, 700); }, 700);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [runId, onScreen]); }, [runId, onScreen]);
const busy = working || screenBusy;
const asking = waiting || menu !== null; const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu. // Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => { const choose = (index: number) => {
@@ -452,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
setFollow((n) => n + 1); setFollow((n) => n + 1);
} else toast.error(t("chat.notSent")); } else toast.error(t("chat.notSent"));
}; };
// "No, and tell … differently": the CLI drops back to its prompt, where
// the note is what the user would type next.
const denyWithNote = (index: number, note: string) => {
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
setMenu(null);
window.setTimeout(() => void command(note), 800);
};
// A hidden tab says when this session waits on the user or its turn ended. // A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, asking: false }); const was = useRef({ working: false, asking: false });
useEffect(() => { useEffect(() => {
if (asking) setTabBadge(runId, "waiting"); if (asking) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null); else if (busy) setTabBadge(runId, null);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done"); else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working, asking }; was.current = { working: busy, asking };
}, [runId, working, asking]); }, [runId, busy, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]); useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({ const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0, tokens: 0,
@@ -476,7 +747,20 @@ export const PaneChat = memo(function PaneChat({
// Stable, so typing in the prompt box does not re-render the conversation // Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized). // (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]); const openTerminal = useCallback(
() => showTerminal(runId, sessionId, run.tool),
[runId, sessionId, run.tool]
);
// The conversation on screen: an artifact's versions are rebuilt from it.
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
// An artifact card opens in this chat's own panel, like Claude Desktop's —
// not the workspace's Changes panel a plain file link goes to.
const openArtifact = useCallback(
(named: string) => {
setPreview(resolveIn(cwd, named));
},
[cwd]
);
const openFile = useCallback( const openFile = useCallback(
(named: string) => { (named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder. // A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
@@ -514,7 +798,7 @@ export const PaneChat = memo(function PaneChat({
const send = async () => { const send = async () => {
if (!text || uploading) return; if (!text || uploading) return;
const out = promptForPane(prompt, images); const out = promptForPane(prompt, images, claude);
if (!(await command(out.text, out.files))) return; if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1); setFollow((n) => n + 1);
setPrompt(""); setPrompt("");
@@ -526,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
<div <div
ref={rootRef} ref={rootRef}
data-prompt-drop data-prompt-drop
// The last chat focused is where the Changes panel's comments go.
onFocusCapture={() => notePaneFocus(runId)}
className="relative flex min-h-0 flex-1 [container-type:inline-size]" className="relative flex min-h-0 flex-1 [container-type:inline-size]"
style={{ "--chat-side": `${side * 100}%` } as CSSProperties} style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
> >
@@ -542,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
onContext={onContext} onContext={onContext}
onNotStarted={setNotStarted} onNotStarted={setNotStarted}
onOpenFile={openFile} onOpenFile={openFile}
onOpenArtifact={openArtifact}
onMessages={setConvMessages}
/> />
{!outlineOpen && !preview && ( {!outlineOpen && !preview && (
<button <button
@@ -558,7 +846,7 @@ export const PaneChat = memo(function PaneChat({
)} )}
</div> </div>
<div className="mx-4"> <div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]"> <div className="mx-auto w-full max-w-3xl">
{menu && ( {menu && (
<PaneMenuCard <PaneMenuCard
// A new question starts with its field closed. // A new question starts with its field closed.
@@ -566,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
menu={menu} menu={menu}
onChoose={choose} onChoose={choose}
onAnswer={answer} onAnswer={answer}
onDenyNote={denyWithNote}
onStep={(dir) => {
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
}}
onCancel={() => { onCancel={() => {
if (interruptRun(runId)) setMenu(null); if (interruptRun(runId)) setMenu(null);
else toast.error(t("chat.notSent")); else toast.error(t("chat.notSent"));
@@ -575,7 +867,7 @@ export const PaneChat = memo(function PaneChat({
{/* In the card's place while the CLI asks: hidden, never unmounted, {/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */} so a draft or an upload in flight survives the question. */}
<div <div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`} className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
> >
<PromptEditor <PromptEditor
bare bare
@@ -602,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
onAttach={(items) => setImages((prev) => addAttachments(prev, items))} onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/> />
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />} {claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
{run.tool === "codex" &&
// Codex's `/model` (then its effort) and `/permissions` are
// numbered pickers: typed here, answered on the menu card.
(
[
["/permissions", t("fields.permissionMode")],
["/model", t("fields.model")],
] as const
).map(([line, label]) => (
<button
key={line}
type="button"
data-testid={`pane-chat-codex-${line.slice(1)}`}
onClick={() => void command(line)}
title={t("chat.codexPicker")}
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
{label}
</button>
))}
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<ContextMeter tokens={context.tokens} model={context.model} /> <ContextMeter tokens={context.tokens} model={context.model} />
{claude && ( {claude && (
@@ -617,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
}} }}
/> />
)} )}
{working && !text ? ( {busy && !text ? (
<button <button
type="button" type="button"
data-testid="pane-chat-stop" data-testid="pane-chat-stop"
@@ -630,8 +942,13 @@ export const PaneChat = memo(function PaneChat({
}} }}
aria-label={t("chat.stop")} aria-label={t("chat.stop")}
title={t("chat.stop")} title={t("chat.stop")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4" className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
> >
{/* A turning ring: the turn is still going. */}
<span
aria-hidden
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
/>
<Square className="h-3 w-3 fill-current" /> <Square className="h-3 w-3 fill-current" />
</button> </button>
) : ( ) : (
@@ -657,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
<FilePreview <FilePreview
path={preview} path={preview}
scope={scope} scope={scope}
messages={convMessages}
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
// Beside the chat when the pane has room, over it when not. // 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" 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"
+7 -7
View File
@@ -70,7 +70,7 @@ import {
} from "../../lib/paneLayout"; } from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs"; import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi"; import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft"; import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste"; import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptForPane } from "./RunSetup"; import { promptForPane } from "./RunSetup";
@@ -238,15 +238,15 @@ function PaneMenu({
const ITEM = 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"; "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 /** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The * (`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, * 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. */ * 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 /** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */ * the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) { export function showTerminal(runId: string, sessionId: string, tool?: string) {
const draft = readChatDraft(sessionId); const draft = readChatDraft(sessionId);
const { text, files } = promptForPane(draft.prompt, draft.images); const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []); if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false); setSessionChat(sessionId, false);
} }
@@ -254,7 +254,7 @@ export function showTerminal(runId: string, sessionId: string) {
function ChatToggle({ run }: { run?: RunHandle }) { function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId); const chat = useSessionChat(run?.sessionId);
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null; if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
const sessionId = run.sessionId; const sessionId = run.sessionId;
const label = chat ? t("panes.showTerminal") : t("panes.showChat"); const label = chat ? t("panes.showTerminal") : t("panes.showChat");
return ( return (
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
title={label} title={label}
aria-label={label} aria-label={label}
onClick={() => { onClick={() => {
if (chat) return showTerminal(run.id, sessionId); if (chat) return showTerminal(run.id, sessionId, run.tool);
const draft = readChatDraft(sessionId); const draft = readChatDraft(sessionId);
const text = takeRunInput(run.id); const text = takeRunInput(run.id);
if (text) if (text)
@@ -765,7 +765,7 @@ export function Divider({
onRatio, onRatio,
label, label,
}: { }: {
gridRef: React.RefObject<HTMLDivElement>; gridRef: React.RefObject<HTMLDivElement | null>;
dir: SplitDir; dir: SplitDir;
at: number; at: number;
box: Rect; box: Rect;
+120 -32
View File
@@ -11,7 +11,10 @@
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`, * other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a panel row (Files, * remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`) * Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box; * as an `@path` chip — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, or pastes from a
* file manager's Copy its OS clipboard holds, never uploaded —
* a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over * its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`); * the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh; * a borderless (`bare`) one grows with its text up to 40vh;
@@ -67,9 +70,17 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import { import {
ATTACH_MIME, ATTACH_MIME,
addAttachments, addAttachments,
claudeMention,
copiedFilePaths,
droppedAttachments, droppedAttachments,
droppedSkill, droppedSkill,
droppedText, droppedText,
isLocalBrowser,
localAttachment,
localCandidates,
sameFilesHere,
fileManagerPaths,
isClipboardBitmap,
SKILL_MIME, SKILL_MIME,
rememberUploads, rememberUploads,
} from "../../lib/promptAttach"; } from "../../lib/promptAttach";
@@ -203,24 +214,35 @@ interface PromptEditorProps {
export type PromptImage = { path: string; url?: string; name: string }; export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each file's saved path — which is how /** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */ * the CLI is handed an image or a file; for `claude`, as `@` mentions. */
export function promptWithImages(text: string, images: PromptImage[]): string { export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "); return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
.filter(Boolean)
.join(" ");
} }
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i; const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane: the text with every non-image file /** The prompt as pasted into a live pane. `claude`: one paste, every file an
* in it (as `promptWithImages`), and the images' paths apart — each goes in * `@` mention, and a trailing space so a mention at the end does not leave
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every * the `@` file suggester open to swallow the Enter after it. Any other tool:
* platform (a Windows path inside a paste of text stayed a plain path). */ * the text with every non-image file in it, and the images' paths apart,
* each in a paste of its own (how `codex` attaches a pasted image path). */
export function promptForPane( export function promptForPane(
text: string, text: string,
images: PromptImage[] images: PromptImage[],
claude = false
): { text: string; files: string[] } { ): { text: string; files: string[] } {
if (claude) {
const all = promptWithImages(text, images, true);
return { text: images.length ? `${all} ` : all, files: [] };
}
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path); const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return { return {
text: promptWithImages(text, images.filter((i) => !isImage(i))), text: promptWithImages(
text,
images.filter((i) => !isImage(i))
),
files: images.filter(isImage).map((i) => i.path), files: images.filter(isImage).map((i) => i.path),
}; };
} }
@@ -290,7 +312,7 @@ export function PromptEditor({
Promise.all([ Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })), api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })), api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })), api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
]).then(([c, sk, pl]) => { ]).then(([c, sk, pl]) => {
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one // A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
// installed at both user and project scope is listed once. // installed at both user and project scope is listed once.
@@ -390,7 +412,11 @@ export function PromptEditor({
// A pasted or dropped file is saved on the server's machine (the same // A pasted or dropped file is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail or a name chip; // route the terminal's drop uses) and shown as a thumbnail or a name chip;
// its path is added to the prompt only when it is sent (promptWithImages). // its path is added to the prompt only when it is sent (promptWithImages).
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null); const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
null
);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
const [viewing, setViewing] = useState<PromptImage | null>(null); const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null); const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
const uploading = upload === "uploading"; const uploading = upload === "uploading";
@@ -401,20 +427,77 @@ export function PromptEditor({
const picked = Array.from(list ?? []); const picked = Array.from(list ?? []);
if (picked.length === 0) return false; if (picked.length === 0) return false;
setUpload("uploading"); setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropFile(f, lane))) // Each file on its own: one refused file does not cost the others.
.then((saved) => { void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
setUpload(null); const added: PromptImage[] = [];
const added = picked.map((f, i) => ({ const errors: unknown[] = [];
path: saved[i]!.path, results.forEach((r, i) => {
if (r.status === "rejected") return void errors.push(r.reason);
const f = picked[i]!;
added.push({
path: r.value.path,
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined, url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name, name: f.name,
})); });
});
if (added.length) {
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url }))); rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
keepError.current = errors.length > 0; // these came with the error
onImagesChange((prev) => [...prev, ...added]); onImagesChange((prev) => [...prev, ...added]);
}) }
.catch(() => setUpload("failed")); // Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
setUpload(
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
);
});
return true; return true;
}; };
/** A file manager's copy pasted with no file beside it, only `file://`
* text: attached by those paths, as the terminal pastes them. */
const pastedLocalFiles = (dt: DataTransfer | null | undefined) => {
const paths = isLocalBrowser() ? copiedFilePaths(dt) : null;
if (!paths) return false;
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
return true;
};
/** Dropped or pasted files: by their own paths when the browser is on the
* dashboard's machine and the server finds the same files there (a file
* manager hands over `file://` paths beside the bytes) or its file manager
* holds them (a paste: its clipboard; a drop: its selection — no upload,
* no size limit), else uploaded. */
const attachDropped = (
dt: DataTransfer | null | undefined,
list: FileList | File[] | null | undefined,
from: "paste" | "drop"
) => {
const files = Array.from(list ?? []);
if (files.length === 0) return false;
if (!isLocalBrowser()) return attachImages(files);
const candidates = localCandidates(dt, files);
setUpload("uploading");
void (async () => {
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
paths ??= await fileManagerPaths(files, from);
if (!paths) return void attachImages(files);
setUpload(null);
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
})();
return true;
};
// Anything attached since — a path, a panel row, a + menu pick — ends a past
// upload's error; not the files that uploaded beside the one refused.
const keepError = useRef(false);
const imageCount = useRef(images.length);
useEffect(() => {
const grew = images.length > imageCount.current;
imageCount.current = images.length;
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
keepError.current = false;
}, [images.length]);
const removeImage = (img: PromptImage) => { const removeImage = (img: PromptImage) => {
if (img.url) URL.revokeObjectURL(img.url); if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img)); onImagesChange((prev) => prev.filter((x) => x !== img));
@@ -443,7 +526,7 @@ export function PromptEditor({
} else if (skill) { } else if (skill) {
e.preventDefault(); e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`); onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault(); } else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) e.preventDefault();
else { else {
// Only text (a file manager's or an editor's `file://` list, a // Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste. // selection, a link): what the terminal beneath would paste.
@@ -608,16 +691,21 @@ export function PromptEditor({
onBlur={() => setTrigger(null)} onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={(e) => { onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault(); const pasted = Array.from(e.clipboardData?.files ?? []);
else { const text = e.clipboardData?.getData?.("text/plain") ?? "";
// Cells from a spreadsheet come with a picture of them: the text
// is what was copied (isClipboardBitmap).
const textWithPicture =
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
e.preventDefault();
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
else if (text.length > PASTE_AS_FILE_CHARS) {
// A very long paste goes in as a text file, like Claude Desktop: // 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 // the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit). // argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? ""; e.preventDefault();
if (text.length > PASTE_AS_FILE_CHARS) { attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
} }
}} }}
placeholder={placeholder} placeholder={placeholder}
@@ -652,10 +740,10 @@ export function PromptEditor({
</div> </div>
{upload && ( {upload && (
<p <p
role={upload === "failed" ? "alert" : "status"} role={upload === "uploading" ? "status" : "alert"}
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`} className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
> >
{t(`drop.${upload}`)} {t(`drop.${upload}`, { mb: limitMb })}
</p> </p>
)} )}
{kind && ( {kind && (
@@ -797,7 +885,7 @@ export function CwdAutocomplete({
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setActive((a) => Math.max(0, a - 1)); setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") { } else if (isSubmitEnter(e)) {
if (open && flat[active]) { if (open && flat[active]) {
e.preventDefault(); e.preventDefault();
choose(flat[active]); choose(flat[active]);
+198 -38
View File
@@ -16,8 +16,18 @@
* (`lib/terminalTheme.ts`), recoloured live without re-attaching. * (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server * 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 * (`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 * local terminal does on a drop — as `@` mentions in a `claude` pane, which
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with * reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop — except a PASTED image in a
* `claude` pane of a local-mode dashboard opened on its own machine, which is
* sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
* desktop clipboard itself, uploading nothing — and a file manager's files
* pasted or dropped on that machine go in by the paths it holds them under
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
* `selected-files`), with no upload and no size limit
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be * `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. * pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission * Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -38,7 +48,9 @@
* most every 150 ms. Several browsers on one pane share ONE size: the server * 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 * names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke * the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`). * here takes the lead back (`resize` with `drive: true`). Cells are drawn by
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
* the word-move keys xterm 5 sent (`altArrowSeq`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { import {
@@ -53,7 +65,7 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm"; import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas"; import type { WebglAddon } from "@xterm/addon-webgl";
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard"; import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
import type { SearchAddon } from "@xterm/addon-search"; import type { SearchAddon } from "@xterm/addon-search";
import { ArrowDown, ArrowUp, X } from "lucide-react"; import { ArrowDown, ArrowUp, X } from "lucide-react";
@@ -62,12 +74,24 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme"; import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard"; import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste"; import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
import {
claudePastes,
copiedFilePaths,
isClipboardBitmap,
droppedFilePaths,
fileManagerPaths,
isLocalBrowser,
localCandidates,
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput"; import { attachImeInput } from "../../lib/imeInput";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize"; import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed"; type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */ /** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader"; export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -101,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
* Workspace chunk. The result is cached, so every later pane (and a test that * Workspace chunk. The result is cached, so every later pane (and a test that
* awaits `loadXterm()` first) sets up synchronously. */ * awaits `loadXterm()` first) sets up synchronously. */
const importXterm = async () => { const importXterm = async () => {
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([ const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
import("@xterm/xterm"), import("@xterm/xterm"),
import("@xterm/addon-fit"), import("@xterm/addon-fit"),
import("@xterm/addon-canvas"), import("@xterm/addon-webgl"),
import("@xterm/addon-clipboard"), import("@xterm/addon-clipboard"),
import("@xterm/addon-search"), import("@xterm/addon-search"),
import("@xterm/addon-web-links"), import("@xterm/addon-web-links"),
@@ -112,7 +136,7 @@ const importXterm = async () => {
return { return {
Terminal: xterm.Terminal, Terminal: xterm.Terminal,
FitAddon: fit.FitAddon, FitAddon: fit.FitAddon,
CanvasAddon: canvas.CanvasAddon, WebglAddon: webgl.WebglAddon,
SearchAddon: search.SearchAddon, SearchAddon: search.SearchAddon,
WebLinksAddon: links.WebLinksAddon, WebLinksAddon: links.WebLinksAddon,
// The addon's typings declare one argument, its code takes (base64, provider). // The addon's typings declare one argument, its code takes (base64, provider).
@@ -136,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
return xtermLoading; return xtermLoading;
} }
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
* readline's backward-word / forward-word and whose up/down stay Alt's. */
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
switch (key) {
case "ArrowLeft":
return mac ? "\x1bb" : "\x1b[1;5D";
case "ArrowRight":
return mac ? "\x1bf" : "\x1b[1;5C";
case "ArrowUp":
return mac ? null : "\x1b[1;5A";
case "ArrowDown":
return mac ? null : "\x1b[1;5B";
default:
return null;
}
}
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */ /** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
const RETRY_MIN_MS = 1500; const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000; const RETRY_MAX_MS = 30_000;
@@ -160,6 +203,10 @@ interface TerminalViewProps {
* so it is moved out of the viewport with a transform, which keeps its * 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). */ * layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean; covered?: boolean;
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
* as its bare path and any other file as an `@` mention (`claudePastes`),
* any other tool its plain path. */
tool?: string;
} }
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and /** Memoized: the Workspace re-renders its pane tree on every lane_update, and
@@ -170,6 +217,7 @@ export const TerminalView = memo(function TerminalView({
onExit, onExit,
autoFocus = true, autoFocus = true,
covered = false, covered = false,
tool,
}: TerminalViewProps) { }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on // Read through a ref: an inline `onExit` from the parent changes identity on
@@ -193,6 +241,8 @@ export const TerminalView = memo(function TerminalView({
const tRef = useRef(t); const tRef = useRef(t);
tRef.current = t; tRef.current = t;
const [drop, setDrop] = useState<DropState | null>(null); const [drop, setDrop] = useState<DropState | null>(null);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
// The socket dropped after it had been up: keystrokes are not reaching tmux. // The socket dropped after it had been up: keystrokes are not reaching tmux.
const [reconnecting, setReconnecting] = useState(false); const [reconnecting, setReconnecting] = useState(false);
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane. // Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
@@ -207,10 +257,10 @@ export const TerminalView = memo(function TerminalView({
// only goes away when the pointer leaves the terminal itself. // only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0); const dragDepth = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (state: DropState) => { const flash = (state: DropState, ms = 2500) => {
setDrop(state); setDrop(state);
if (flashTimer.current) clearTimeout(flashTimer.current); if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setDrop(null), 2500); flashTimer.current = setTimeout(() => setDrop(null), ms);
}; };
useEffect( useEffect(
() => () => { () => () => {
@@ -221,21 +271,30 @@ export const TerminalView = memo(function TerminalView({
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler). // Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false); const ctrlVRef = useRef(false);
const { local: localMode } = usePermissions();
// Ask early whether `^V` can reach this machine's clipboard, so the first
// pasted screenshot already takes the right way.
useEffect(() => {
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
}, [tool, localMode]);
const onDrop = (e: DragEvent) => { const onDrop = (e: DragEvent) => {
if (hasText(e)) { if (hasText(e)) {
e.preventDefault(); e.preventDefault();
dragDepth.current = 0; dragDepth.current = 0;
setDrop(null); setDrop(null);
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
const text = e.dataTransfer.getData("text/plain"); const text = e.dataTransfer.getData("text/plain");
if (text) termRef.current?.paste(text + " "); if (local) pastePaths(local);
else if (text) termRef.current?.paste(text + " ");
termRef.current?.focus(); termRef.current?.focus();
return; return;
} }
if (!hasFiles(e)) return; if (!hasFiles(e)) return;
e.preventDefault(); e.preventDefault();
dragDepth.current = 0; dragDepth.current = 0;
void attachFiles(Array.from(e.dataTransfer.files)); const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
}; };
// A pasted image (a screenshot) goes the way of a drop: xterm only reads // A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -247,26 +306,83 @@ export const TerminalView = memo(function TerminalView({
const ctrlV = ctrlVRef.current; const ctrlV = ctrlVRef.current;
ctrlVRef.current = false; ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []); const files = Array.from(e.clipboardData?.files ?? []);
// A file manager's copied files reach a browser as `file://` text only.
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
if (local) {
e.preventDefault();
e.stopPropagation();
pastePaths(local);
return;
}
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return; if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
// Cells from a spreadsheet, a page's selection: text WITH a picture of
// it. A terminal pastes the text there, so does this.
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (files.length > 0) void attachFiles(files); // On the dashboard's own machine the browser's clipboard IS the pane's:
// `claude` reads a pasted image itself on ^V, as in a native terminal —
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
const nativeImage =
tool === "claude" &&
localMode &&
isLocalBrowser() &&
readNativePaste() &&
nativePasteWorks() &&
files.length > 0 &&
files.every(isClipboardBitmap);
if (nativeImage) {
termRef.current?.input(nativePasteKey(), true);
return;
}
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
else termRef.current?.input("\x16", true); else termRef.current?.input("\x16", true);
}; };
const attachFiles = async (files: File[]) => { /** Pastes files' paths as a local terminal hands them over — paste(), not
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
* rather than typed keystrokes. A `claude` pane gets its images as bare
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
* (`claudePastes`). */
const pastePaths = (paths: string[]) => {
const term = termRef.current;
if (!term) return;
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
term.paste(text + " ");
term.focus();
};
/** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths
* that machine's file manager holds them under (a paste: its clipboard; a
* drop: its selection, what the drag carries — no upload, no size limit,
* as a native terminal), else the paths the uploads were saved at. A
* refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
if (files.length === 0) return; if (files.length === 0) return;
setDrop("uploading"); setDrop("uploading");
try { let paths = local && (await sameFilesHere(local, files)) ? local : null;
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId))); paths ??= await fileManagerPaths(files, from);
// paste(), not a raw send: it honours bracketed-paste mode, so claude const errors: unknown[] = [];
// sees pasted paths rather than typed keystrokes. if (!paths) {
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " "); const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
termRef.current?.focus(); paths = results.flatMap((r) => {
setDrop(null); if (r.status === "fulfilled") return [r.value.path];
} catch { errors.push(r.reason);
flash("failed"); return [];
});
} }
if (paths.length) pastePaths(paths);
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
if (!errors.length) setDrop(null);
else if (!tooBig) flash("failed");
// A terminal has no + menu: on this machine, a paste goes by path.
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
}; };
useEffect(() => { useEffect(() => {
@@ -274,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
const attach = ({ const attach = ({
Terminal, Terminal,
FitAddon, FitAddon,
CanvasAddon, WebglAddon,
SearchAddon, SearchAddon,
WebLinksAddon, WebLinksAddon,
ClipboardWithProvider, ClipboardWithProvider,
@@ -284,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
// which on Windows lacks Vietnamese precomposed diacritics — the OS then // which on Windows lacks Vietnamese precomposed diacritics — the OS then
// substitutes those specific glyphs from a different face with a // substitutes those specific glyphs from a different face with a
// mismatched advance width, corrupting the cell it's drawn into even // mismatched advance width, corrupting the cell it's drawn into even
// under the canvas renderer (which only stops a glyph from shoving its // under the WebGL renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is // *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports // already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too. Settings → // its `vietnamese` subset so this covers the diacritics too. Settings →
@@ -330,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
// face whose advance width differs, so glyphs shove their neighbours along // face whose advance width differs, so glyphs shove their neighbours along
// and the row's tail spills onto the row below. The buffer is correct // and the row's tail spills onto the row below. The buffer is correct
// throughout (copying the line yields clean text); only the painting drifts, // throughout (copying the line yields clean text); only the painting drifts,
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D // and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
// context keeps the DOM renderer rather than losing the terminal. // cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
let canvas: CanvasAddon | null = null; // keeps the DOM renderer rather than losing the terminal, and so does a
// pane whose context the browser takes back (GPU reset, or past the
// browser's cap of live contexts with many panes open) — disposing the
// addon hands the drawing back to the DOM renderer.
let gpu: WebglAddon | null = null;
try { try {
canvas = new CanvasAddon(); const addon = new WebglAddon();
term.loadAddon(canvas); term.loadAddon(addon);
gpu = addon;
addon.onContextLoss(() => {
addon.dispose();
if (gpu === addon) gpu = null;
});
} catch { } catch {
canvas = null; gpu = null;
} }
fit.fit(); // A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the // Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went // page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor. // nowhere. A click elsewhere still moves focus away, as in any editor.
@@ -427,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
term.input("\x1f", true); term.input("\x1f", true);
return false; return false;
} }
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
// ESC b / ESC f for left/right on macOS — the same keys tmux,
// bash and claude's prompt have always been handed from this pane.
const altArrow =
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
if (altArrow) {
ev.preventDefault();
term.input(altArrow, true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false; if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault(); if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true; return true;
@@ -469,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
*/ */
const sendSizeNow = (drive = false) => { const sendSizeNow = (drive = false) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return; if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return; if (!hasBox() || term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive })); ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
}; };
// This browser is the one in use now: back to its own size, and the // This browser is the one in use now: back to its own size, and the
@@ -506,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
// scrollback as torn fragments along the left margin, visible as soon as // scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load, // you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each reconnect. // lane switch, and each reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; // A hidden terminal sends none: the server attaches it at the window's
// own size, so a pane on a background tab is never resized by it.
const size =
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`); const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock; ws = sock;
wsRef.current = sock; wsRef.current = sock;
@@ -554,6 +697,7 @@ export const TerminalView = memo(function TerminalView({
} }
} }
term.write(data); term.write(data);
notePaneOutput(runId);
} }
}; };
sock.onclose = (e?: CloseEvent) => { sock.onclose = (e?: CloseEvent) => {
@@ -600,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
if (!el || !el.clientWidth || !el.clientHeight) return; if (!el || !el.clientWidth || !el.clientHeight) return;
if (follow) return scaleToFit(); // the driver's grid, not ours if (follow) return scaleToFit(); // the driver's grid, not ours
fit.fit(); // a real change fires onResize, which reports it fit.fit(); // a real change fires onResize, which reports it
// A tab shown for the first time may fit to the very size xterm already
// had (no onResize), yet tmux has never heard it: report it anyway.
sendSize();
}; };
const resizeObserver = new ResizeObserver(refit); const resizeObserver = new ResizeObserver(refit);
if (containerRef.current) resizeObserver.observe(containerRef.current); if (containerRef.current) resizeObserver.observe(containerRef.current);
@@ -638,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
resizeDisposable.dispose(); resizeDisposable.dispose();
dataDisposable.dispose(); dataDisposable.dispose();
ws?.close(); ws?.close();
canvas?.dispose(); gpu?.dispose();
term.dispose(); term.dispose();
termRef.current = null; termRef.current = null;
}; };
@@ -716,6 +863,17 @@ export const TerminalView = memo(function TerminalView({
rows.push(row); rows.push(row);
} }
return { rows, cols: term.cols }; return { rows, cols: term.cols };
},
// Plain text of the rows on screen, dim cells kept (a status line's
// `esc to interrupt` is dim): the chat's busy read.
() => {
const term = termRef.current;
if (!term) return [];
const buf = term.buffer.active;
const rows: string[] = [];
for (let y = 0; y < term.rows; y++)
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
return rows;
} }
), ),
[runId] [runId]
@@ -872,10 +1030,12 @@ export const TerminalView = memo(function TerminalView({
<div <div
data-testid="terminal-drop" data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${ className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200" drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`} }`}
> >
{t(`drop.${drop}`)} {t(`drop.${drop}`, { mb: limitMb })}
</div> </div>
)} )}
</div> </div>
@@ -2,13 +2,14 @@
* @file ChatAddMenu.test.tsx * @file ChatAddMenu.test.tsx
* @description The `+` menu's existing-resource pickers: Project files (a * @description The `+` menu's existing-resource pickers: Project files (a
* search with folders, attached as `@path`), the workspace's Context files * search with folders, attached as `@path`), the workspace's Context files
* (files only, none missing) and this browser's Recent uploads. * (files only, none missing), this browser's Recent uploads and, for a
* browser on the dashboard's machine, a file chosen in the OS's own picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
import { ChatAddMenu } from "../ChatAddMenu"; import { ChatAddMenu } from "../ChatAddMenu";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
api: { api: {
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) }, run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
lanes: { lanes: {
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
sources: vi.fn().mockResolvedValue({ sources: vi.fn().mockResolvedValue({
sources: [ sources: [
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false }, { id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
@@ -61,6 +63,28 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => vi.unstubAllGlobals());
describe("ChatAddMenu — a file on this computer", () => {
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
const onAttach = renderMenu();
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
await vi.waitFor(() =>
expect(onAttach).toHaveBeenCalledWith([
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
])
);
});
it("is not offered to a browser on another machine", () => {
// Only a loopback address: a name that merely starts with "localhost" is not one.
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
renderMenu();
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
});
});
describe("ChatAddMenu — existing resources", () => { describe("ChatAddMenu — existing resources", () => {
it("searches the project, folders included, and attaches a pick as @path", async () => { it("searches the project, folders included, and attaches a pick as @path", async () => {
const onAttach = renderMenu(); const onAttach = renderMenu();
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}), }),
gitRawUrl: () => "", gitRawUrl: () => "",
}, },
artifacts: {
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
},
sessions: { sessions: {
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`, readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
readFile: vi.fn((_id: string, p: string) => readFile: vi.fn((_id: string, p: string) =>
@@ -75,7 +79,11 @@ const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
describe("FilePreview", () => { describe("FilePreview", () => {
it("reads a workspace file through the lane", async () => { it("reads a workspace file through the lane", async () => {
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />); render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy(); expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts"); expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
}); });
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
it("reads Windows paths the same way", async () => { it("reads Windows paths the same way", async () => {
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" }; const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />); render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy(); expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts"); expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
cleanup(); cleanup();
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt"; const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
); );
expect(screen.getByText("No preview for this file here")).toBeTruthy(); expect(screen.getByText("No preview for this file here")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "a.ts" })); fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
expect(await screen.findByText("export const a = 1;")).toBeTruthy(); expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
}); });
}); });
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
expect(screen.getByText("No preview for this file here")).toBeTruthy(); expect(screen.getByText("No preview for this file here")).toBeTruthy();
}); });
}); });
describe("the artifact view", () => {
const html = (lines: string[]) =>
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
available: true,
path: "page.html",
rev: "work",
binary: false,
truncated: false,
lines,
} as never);
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
html(["<button>go</button>"]);
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
fireEvent.click(screen.getByTestId("artifact-tab-code"));
expect(screen.queryByTestId("artifact-frame")).toBeNull();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
)
).toBeTruthy();
cleanup();
});
it("steps back to an older version the conversation wrote", async () => {
html(["<p>two</p>"]);
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
type: "assistant" as const,
timestamp: null,
content: [{ type: "tool_use" as const, id, name, input }],
});
const messages = [
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
];
render(
<FilePreview
path="/w/page.html"
scope={scope}
onClose={() => {}}
messages={messages as never}
/>
);
const versions = await screen.findByTestId("artifact-versions");
expect(versions.textContent).toContain("2");
expect(
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two");
fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
cleanup();
});
it("saves a workspace file to the Artifacts library by its path", async () => {
html(["<p>x</p>"]);
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
await screen.findByTestId("artifact-frame");
fireEvent.click(screen.getByTestId("artifact-share"));
await vi.waitFor(() =>
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
laneId: 4,
path: "page.html",
title: "page.html",
sessionId: "s1",
})
);
cleanup();
});
});
@@ -7,9 +7,11 @@
* activity panel, collapsed into a file button with the output count, whose * 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 * 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 * 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 (its hooks, or the pane's
* own busy line / fresh output), pressing Esc in the pane;
* and the menu the CLI asks with, shown as buttons that press its keys, a * and the menu the CLI asks with, shown as buttons that press its keys, a
* message sent meanwhile typed as its "Type something" answer. * message sent meanwhile typed as its "Type something" answer; a Codex
* terminal's Model / Permission mode buttons typing its own pickers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -22,11 +24,14 @@ import {
answerRunMenu, answerRunMenu,
chooseRunOption, chooseRunOption,
interruptRun, interruptRun,
onPaneOutput,
readRunBusy,
readRunMenu, readRunMenu,
submitToRun, submitToRun,
type PaneMenu, type PaneMenu,
} from "../../../lib/panePaste"; } from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges"; import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../conversation/ConversationView", () => ({ vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file. // Stands in for a turn's "Edited N files" card: a click names a file.
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true), submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true), interruptRun: vi.fn(() => true),
readRunMenu: vi.fn(() => null), readRunMenu: vi.fn(() => null),
readRunBusy: vi.fn(() => false),
chooseRunOption: vi.fn(() => true), chooseRunOption: vi.fn(() => true),
answerRunMenu: vi.fn(() => true), answerRunMenu: vi.fn(() => true),
stepRunMenu: vi.fn(() => true),
notePaneFocus: vi.fn(),
onPaneOutput: vi.fn(() => () => {}),
})); }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
} }
}); });
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
// omp, Cursor or Codex without its hooks: only the terminal knows.
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(true);
try {
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
vi.mocked(readRunBusy).mockReturnValue(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
vi.mocked(readRunBusy).mockReturnValue(false);
}
});
it("counts a tool with no known busy line as working while its pane draws", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(null);
const fns: (() => void)[] = [];
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
fns.push(fn);
return () => {};
});
try {
renderChat();
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
fns.forEach((fn) => fn());
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
await act(async () => {
await vi.advanceTimersByTimeAsync(2800);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
vi.mocked(readRunBusy).mockReturnValue(false);
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
}
});
it("keeps Send for a session at rest", async () => { it("keeps Send for a session at rest", async () => {
// With session_id the server answers EVERY agent of the session (its // With session_id the server answers EVERY agent of the session (its
// status filter does not apply): the main agent is there, waiting. // status filter does not apply): the main agent is there, waiting.
@@ -430,7 +487,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed"); expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument(); expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send")); fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []); expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull()); await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
}); });
@@ -462,7 +519,9 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send")); fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith( expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1", "agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts', // A trailing space: an @ mention at the end would leave claude's file
// suggester open, and it takes the Enter.
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
[] []
); );
}); });
@@ -551,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
vi.mocked(readRunMenu).mockReturnValue(null); vi.mocked(readRunMenu).mockReturnValue(null);
} }
}); });
it("re-reads its transcript when the pane draws, at most once a second", async () => {
// Every listener the chat put on the pane (the re-read, the busy clock).
const fns: (() => void)[] = [];
const drew = () => fns.forEach((fn) => fn());
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
fns.push(fn);
return () => {};
});
const seen: unknown[] = [];
const off = eventBus.subscribe((m) => {
if (m.type === "transcript_changed") seen.push(m.data);
});
try {
renderChat();
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
vi.useFakeTimers();
drew();
drew();
drew();
expect(seen).toEqual([{ session_id: "s1" }]);
await act(async () => {
await vi.advanceTimersByTimeAsync(1100);
});
expect(seen).toHaveLength(2);
} finally {
vi.useRealTimers();
off();
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
}
});
const PERMISSION: PaneMenu = {
question: "Do you want to proceed?",
detail: ["Bash command", "npm test", "Run the tests"],
options: [
{ n: 1, label: "Yes", hint: "", checked: null },
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: -1,
};
it("lays a permission prompt out as a card, each answer its own key", async () => {
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
vi.mocked(chooseRunOption).mockClear();
try {
renderChat();
const card = await screen.findByTestId("pane-chat-permission");
expect(card).toHaveTextContent("Bash command");
expect(card).toHaveTextContent("npm test");
expect(card).toHaveTextContent("Do you want to proceed?");
const options = screen.getAllByTestId("pane-chat-permission-option");
// The key hint is dropped from the label.
expect(options.map((o) => o.textContent)).toEqual([
"Yes",
"Yes, and don't ask again for npm test",
"No, and tell Claude what to do differently",
]);
fireEvent.click(options[1]!);
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
vi.mocked(chooseRunOption).mockClear();
vi.mocked(submitToRun).mockClear();
try {
renderChat();
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
const field = screen.getByTestId("pane-chat-permission-note");
fireEvent.change(field, { target: { value: "use pnpm" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
});
describe("PaneChat — a Codex terminal", () => {
it("types /model and /permissions, whose pickers come back as menu cards", () => {
renderChat({ tool: "codex" });
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
});
it("offers neither to a Claude pane", () => {
renderChat();
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
});
}); });
@@ -56,7 +56,13 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null }; return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}), }),
}, },
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) }, run: {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
localPaths: r({ sizes: [1] }),
clipboardFiles: r({ files: [] }),
selectedFiles: r({ files: [] }),
},
ccConfig: { ccConfig: {
commands: r({ commands: r({
items: [ items: [
@@ -342,6 +348,149 @@ describe("PromptEditor — newlines and images", () => {
}); });
}); });
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
fireEvent.drop(box(), {
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
});
expect(await screen.findByText("demo.mp4")).toBeTruthy();
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
});
// Chrome on Windows pastes a file manager's copy as bytes only: the path
// comes off the OS clipboard, so a file over the upload limit attaches too.
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
});
vi.mocked(api.run.dropFile).mockClear();
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const iso = new File(["x"], "big.iso");
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
});
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
fireEvent.drop(box(), {
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
});
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
});
it("keeps the files that uploaded when another one is refused", async () => {
vi.mocked(api.run.dropFile)
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
fireEvent.drop(box(), {
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
});
expect(await screen.findByText("ok.txt")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
// Anything attached afterwards — here a Files panel row — ends the error.
const data: Record<string, string> = {
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
};
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
expect(await screen.findByText("a.ts")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
it("says a file over the server's limit is too big, and how to attach it", async () => {
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
);
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={() => {}}
/>
);
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
expect((await screen.findByRole("alert")).textContent).toMatch(
/too large to upload \(max 20 MB\).*Choose a file on this computer/
);
});
it("attaches a Files panel row dropped on it as an @path chip, once", () => { it("attaches a Files panel row dropped on it as an @path chip, once", () => {
function Harness() { function Harness() {
const [images, setImages] = useState<PromptImage[]>([]); const [images, setImages] = useState<PromptImage[]>([]);
@@ -593,7 +742,10 @@ describe("promptForPane — images apart, every other file in the text", () => {
// A Windows path inside a paste of text stayed a plain path in claude; a // A Windows path inside a paste of text stayed a plain path in claude; a
// paste that is only the path becomes [Image #n]. // paste that is only the path becomes [Image #n].
const images: PromptImage[] = [ const images: PromptImage[] = [
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" }, {
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
name: "image.png",
},
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" }, { path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" }, { path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" }, { path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
@@ -606,4 +758,29 @@ describe("promptForPane — images apart, every other file in the text", () => {
], ],
}); });
}); });
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
// A bare pasted path stayed plain text on Windows, and a path in the
// command-line prompt is never an image; claude reads an @ mention at submit.
const images: PromptImage[] = [
{
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
name: "image.png",
},
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
];
expect(promptForPane(" look ", images, true)).toEqual({
text:
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
files: [],
});
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
expect(promptWithImages("see", [images[1]!], true)).toBe(
"see @/data/drops/0123456789abcdef-log.txt"
);
});
}); });
@@ -6,13 +6,24 @@
* included) as outgoing binary frames, and * included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not * re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close, * after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching. * recolours live on a dark-theme change without re-attaching, draws through
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
* Alt+arrow word moves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react"; import {
import { TerminalView, loadXterm } from "../TerminalView"; render,
cleanup,
act,
createEvent,
fireEvent,
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste"; import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
const writeMock = vi.fn(); const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = []; const onDataHandlers: Array<(d: string) => void> = [];
@@ -26,8 +37,21 @@ const inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types. // The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement; let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn(); const dropFileMock = vi.fn();
const localPathsMock = vi.fn();
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } }, api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths),
nativePaste: () => nativePasteMock(),
clipboardFiles: () => clipboardFilesMock(),
selectedFiles: () => selectedFilesMock(),
},
},
})); }));
const loadAddonMock = vi.fn(); const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads. // Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -87,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
vi.mock("@xterm/addon-fit", () => ({ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
})); }));
vi.mock("@xterm/addon-canvas", () => ({ // The newest WebGL addon, with the context-loss listener the view gave it.
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })), const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
const addon = {
__webgl: true,
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
return { dispose: vi.fn() };
},
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon;
}),
})); }));
class MockWebSocket { class MockWebSocket {
@@ -114,6 +154,17 @@ class MockWebSocket {
// @ts-expect-error test override // @ts-expect-error test override
global.WebSocket = MockWebSocket; global.WebSocket = MockWebSocket;
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
* tab); returns the restore. */
function withBox(width = 800, height = 600) {
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
return () => {
w.mockRestore();
h.mockRestore();
};
}
describe("TerminalView", () => { describe("TerminalView", () => {
// xterm is a lazy chunk; once loaded every terminal sets up synchronously. // xterm is a lazy chunk; once loaded every terminal sets up synchronously.
beforeAll(() => loadXterm()); beforeAll(() => loadXterm());
@@ -145,11 +196,51 @@ describe("TerminalView", () => {
// it: tmux resizes the window to the newest client, so attaching at the // it: tmux resizes the window to the newest client, so attaching at the
// server's default 80x24 makes claude redraw its UI at 80 columns and // server's default 80x24 makes claude redraw its UI at 80 columns and
// leaves that width in the scrollback (issue #33). // leaves that width in the scrollback (issue #33).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); const restore = withBox();
expect(MockWebSocket.instances).toHaveLength(1); try {
expect(MockWebSocket.instances[0]!.url).toBe( render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28" expect(MockWebSocket.instances).toHaveLength(1);
); expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
} finally {
restore();
}
});
it("a terminal on a hidden tab sends no size until it is shown", () => {
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
// that size resized the real tmux pane under every other client.
let observed: (() => void) | null = null;
const RO = globalThis.ResizeObserver;
// @ts-expect-error test override
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
observed = cb;
}
observe() {}
disconnect() {}
};
vi.useFakeTimers();
let restore = () => {};
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
ws.onopen?.();
expect(ws.sent).toEqual([]);
// The tab is shown: the box gets a size and its size reaches tmux.
restore = withBox();
observed!();
vi.advanceTimersByTime(200);
expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
]);
} finally {
restore();
vi.useRealTimers();
globalThis.ResizeObserver = RO;
}
}); });
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => { it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
@@ -375,34 +466,75 @@ describe("TerminalView", () => {
expect(onExit).toHaveBeenCalledTimes(1); expect(onExit).toHaveBeenCalledTimes(1);
}); });
it("renders through the canvas addon, not xterm's DOM renderer", () => { it("renders through the WebGL addon, not xterm's DOM renderer", () => {
// The DOM renderer aligns a row with one `letter-spacing`, which holds only // The DOM renderer aligns a row with one `letter-spacing`, which holds only
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a // while every glyph shares a font. Vietnamese diacritics and CJK fall to a
// fallback face with a different advance, so glyphs shove their neighbours // fallback face with a different advance, so glyphs shove their neighbours
// and the row's tail spills onto the row below — buffer clean, painting // and the row's tail spills onto the row below — buffer clean, painting
// wrong. The canvas renderer draws each cell at a fixed position instead. // wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe( expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true true
); );
}); });
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const addon = gpu.last!;
expect(addon.dispose).not.toHaveBeenCalled();
addon.lose();
expect(addon.dispose).toHaveBeenCalledTimes(1);
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
expect(altArrowSeq("ArrowUp", true)).toBeNull();
expect(altArrowSeq("a", false)).toBeNull();
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: "ArrowLeft", altKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
// Another modifier with it is xterm's own sequence.
expect(
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
).toBe(true);
});
describe("size reporting (tmux must never be wider than the terminal)", () => { describe("size reporting (tmux must never be wider than the terminal)", () => {
// A tmux pane wider than xterm wraps each over-long row's tail onto the // A tmux pane wider than xterm wraps each over-long row's tail onto the
// next row's left margin, and tmux never repaints a row it believes // next row's left margin, and tmux never repaints a row it believes
// unchanged — the stray characters glued to the left edge after scrolling. // unchanged — the stray characters glued to the left edge after scrolling.
it("reports its size when the socket opens", () => { it("reports its size when the socket opens", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); const restore = withBox();
const ws = MockWebSocket.instances[0]!; try {
ws.onopen?.(); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(ws.sent).toContain( const ws = MockWebSocket.instances[0]!;
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }) ws.onopen?.();
); expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
} finally {
restore();
}
}); });
it("reports a size change xterm made on its own, with no container resize", async () => { it("reports a size change xterm made on its own, with no container resize", async () => {
// Webfont load and page zoom both move cols without resizing the element // Webfont load and page zoom both move cols without resizing the element
// ResizeObserver watches, which is why onResize is the hook. // ResizeObserver watches, which is why onResize is the hook.
const restore = withBox();
onTestFinished(restore);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!; const ws = MockWebSocket.instances[0]!;
ws.onopen?.(); ws.onopen?.();
@@ -512,6 +644,23 @@ describe("TerminalView", () => {
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull()); await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
}); });
it("pastes a local file manager's own paths, uploading nothing", async () => {
dropFileMock.mockReset();
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = {
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
};
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
expect(dropFileMock).not.toHaveBeenCalled();
});
it("pastes every path of several dropped files, images or not", async () => { it("pastes every path of several dropped files, images or not", async () => {
dropFileMock dropFileMock
.mockReset() .mockReset()
@@ -567,6 +716,168 @@ describe("TerminalView", () => {
expect(dropFileMock).toHaveBeenCalledWith(img, "r1"); expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
}); });
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
// paste nothing, so the screenshot is uploaded like a remote browser's.
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: false });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
await Promise.resolve();
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(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
try {
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
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(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
} finally {
localStorage.removeItem("agenthub.terminal.nativePaste");
}
});
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
// the pane gets the file's own path off the OS clipboard — no upload, so
// no size limit (a 3 GB video too), as a native terminal pastes it.
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
Object.defineProperty(video, "size", { value: 3_000_000_000 });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [video], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
expect(dropFileMock).not.toHaveBeenCalled();
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
// Chrome hands a file dragged from Explorer no path; the drag carries the
// items selected there, so the pane gets their own paths — as a native
// terminal types them — and nothing is uploaded, whatever the size.
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
dropFileMock.mockReset();
pasteMock.mockClear();
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const video = new File([new Uint8Array([1])], "big.mkv");
Object.defineProperty(video, "size", { value: 5_000_000_000 });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a dropped file the file manager does not hold selected", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
const notes = new File(["x"], "notes.txt");
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
});
// A copied image FILE is not a screenshot: claude's own paste finds no
// picture on a clipboard holding a file, so it goes by path too.
it("pastes a copied image file by path instead of claude's image paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [logo], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// Cells from a spreadsheet come with a picture of them; a terminal pastes
// the text, so the browser's paste is left to xterm.
it("leaves text that comes with a picture of it to xterm's paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
});
fireEvent(screen.getByTestId("terminal-view"), ev);
expect(ev.defaultPrevented).toBe(false);
expect(inputMock).not.toHaveBeenCalled();
expect(dropFileMock).not.toHaveBeenCalled();
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then // 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 // 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. // pasted like any browser paste: sending ^V for it pasted nothing.
@@ -3,10 +3,12 @@
* @description Settings → Account (server mode, any signed-in role): the * @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member * account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar * 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 * (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
* sidebar shows it at once), its display name * an https link the browser loads itself, `POST /api/me/avatar/url`; the sidebar
* shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the * 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 * current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
* typed (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST * other sign-in ends) and "Sign out other devices" (`POST
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an * /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
* admin's to change, on the Users page — never here. * admin's to change, on the Users page — never here.
@@ -23,15 +25,18 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react"; import { Camera, Link2, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { api, type MyAccount } from "../../lib/api"; import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format"; import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { useAuth } from "../AuthGate"; import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog"; import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar"; import { UserAvatar } from "../users/UserAvatar";
import { PasswordRules } from "../users/PasswordRules";
import { passwordProblems } from "../../lib/passwordRules";
import { SettingsSection } from "./SettingsRow"; import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>; type ServerAccount = Extract<MyAccount, { mode: "server" }>;
@@ -43,6 +48,24 @@ const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unkno
: err.message; : err.message;
}; };
/** Whether `url` loads as an image embedded in this page — the way every
* avatar is shown: no referrer, the cookies a cross-site image gets. */
export function loadsAsImage(url: string, timeoutMs = 10_000): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
const done = (ok: boolean) => {
window.clearTimeout(timer);
img.onload = img.onerror = null;
resolve(ok);
};
const timer = window.setTimeout(() => done(false), timeoutMs);
img.referrerPolicy = "no-referrer";
img.onload = () => done(img.naturalWidth > 0);
img.onerror = () => done(false);
img.src = url;
});
}
export function AccountSettings() { export function AccountSettings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const [account, setAccount] = useState<ServerAccount | null>(null); const [account, setAccount] = useState<ServerAccount | null>(null);
@@ -64,19 +87,23 @@ export function AccountSettings() {
); );
const load = useCallback(() => { const load = useCallback(() => {
setLoadError(null); setLoadError(null);
api.me return api.me
.account() .account()
.then(apply) .then(apply)
.catch((e: Error) => setLoadError(e.message)); .catch((e: Error) => setLoadError(e.message));
}, [apply]); }, [apply]);
useEffect(load, [load]); useEffect(() => {
void load();
}, [load]);
const [retrying, retry] = useSpin(load);
if (loadError) { if (loadError) {
return ( return (
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger"> <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> <span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
<button type="button" className="btn-ghost text-xs" onClick={load}> <button type="button" className="btn-ghost text-xs" onClick={retry} disabled={retrying}>
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")} <RefreshCw className={`h-3.5 w-3.5${retrying ? " animate-spin" : ""}`} />{" "}
{t("common:retry")}
</button> </button>
</div> </div>
); );
@@ -110,18 +137,35 @@ function ProfileCard({
const shown = user.displayName || user.username; const shown = user.displayName || user.username;
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// null = the link field is closed.
const [link, setLink] = useState<string | null>(null);
const run = async (op: () => Promise<MyAccount>, done: string) => { const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true); setBusy(true);
try { try {
onSaved(await op()); onSaved(await op());
toast.success(done); toast.success(done);
return true;
} catch (e) { } catch (e) {
toast.error(errText(t, e)); toast.error(errText(t, e));
return false;
} finally { } finally {
setBusy(false); setBusy(false);
} }
}; };
const applyLink = async () => {
const url = link?.trim();
if (!url) return;
// The avatar routes only redirect to the link, so it shows only if THIS
// browser can load it as an embedded image: a link behind a login (a
// Mattermost /api/v4/users/<id>/image) gets no cookie from another site
// and fails — say so now instead of saving a picture nobody sees.
setBusy(true);
const ok = await loadsAsImage(url);
setBusy(false);
if (!ok) return void toast.error(t("account.avatar.linkUnreachable"));
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
};
const pick = (e: ChangeEvent<HTMLInputElement>) => { const pick = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again e.target.value = ""; // the same file can be picked again
@@ -150,6 +194,18 @@ function ProfileCard({
> >
<Camera className="h-3.5 w-3.5" /> <Camera className="h-3.5 w-3.5" />
</button> </button>
<button
type="button"
className={`btn-ghost h-6 w-6 justify-center p-0 ${link !== null ? "text-accent" : ""}`}
onClick={() => setLink((v) => (v === null ? "" : null))}
disabled={busy}
title={t("account.avatar.fromUrl")}
aria-label={t("account.avatar.fromUrl")}
aria-pressed={link !== null}
data-testid="account-avatar-url-toggle"
>
<Link2 className="h-3.5 w-3.5" />
</button>
{user.avatarAt && ( {user.avatarAt && (
<button <button
type="button" type="button"
@@ -187,6 +243,36 @@ function ProfileCard({
</div> </div>
</div> </div>
</div> </div>
{link !== null && (
<div className="mt-3 flex items-center gap-2">
<input
type="url"
className="input w-full text-sm"
value={link}
placeholder={t("account.avatar.urlPlaceholder")}
aria-label={t("account.avatar.fromUrl")}
autoFocus
disabled={busy}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void applyLink();
} else if (e.key === "Escape") setLink(null);
}}
data-testid="account-avatar-url-input"
/>
<button
type="button"
className="btn-primary flex-shrink-0 text-sm"
onClick={() => void applyLink()}
disabled={busy || !link.trim()}
data-testid="account-avatar-url-apply"
>
{t("account.avatar.urlApply")}
</button>
</div>
)}
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5"> <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")}> <Stat label={t("account.memberSince")}>
{user.createdAt ? formatDateTime(user.createdAt) : "—"} {user.createdAt ? formatDateTime(user.createdAt) : "—"}
@@ -283,13 +369,17 @@ function ProfileForm({
function PasswordForm() { function PasswordForm() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { state } = useAuth();
const username = state.mode === "server" ? state.user?.username : undefined;
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [next, setNext] = useState(""); const [next, setNext] = useState("");
const [again, setAgain] = useState(""); const [again, setAgain] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mismatch = again.length > 0 && again !== next; const mismatch = again.length > 0 && again !== next;
const ready = current.length > 0 && next.length > 0 && again === next && !busy; // The account's own password: every rule (server/lib/auth.js decides too).
const strong = passwordProblems(next, username).length === 0;
const ready = current.length > 0 && strong && again === next && !busy;
const submit = async () => { const submit = async () => {
if (!ready) return; if (!ready) return;
@@ -331,7 +421,7 @@ function PasswordForm() {
aria-label={t("account.currentPassword")} aria-label={t("account.currentPassword")}
/> />
</Field> </Field>
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}> <Field label={t("account.newPassword")}>
<input <input
type="password" type="password"
className="input w-full text-sm" className="input w-full text-sm"
@@ -352,6 +442,9 @@ function PasswordForm() {
/> />
</Field> </Field>
</div> </div>
<div className="mt-3">
<PasswordRules password={next} username={username} />
</div>
{(mismatch || error) && ( {(mismatch || error) && (
<p role="alert" className="mt-2 text-xs text-status-danger"> <p role="alert" className="mt-2 text-xs text-status-danger">
{error ?? t("account.mismatch")} {error ?? t("account.mismatch")}
@@ -17,6 +17,7 @@ import { FileUp } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api, type AgentInstructionsView } from "../../lib/api"; import { api, type AgentInstructionsView } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { useSpin } from "../../hooks/useSpin";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { SettingsSection } from "./SettingsRow"; import { SettingsSection } from "./SettingsRow";
@@ -41,13 +42,16 @@ export function AgentInstructionsSettings() {
const load = () => { const load = () => {
setLoadError(null); setLoadError(null);
api.me return api.me
.instructions() .instructions()
.then(apply) .then(apply)
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err))); .catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
}; };
useEffect(load, []); useEffect(() => {
void load();
}, []);
const [retrying, retry] = useSpin(load);
async function save() { async function save() {
setSaving(true); setSaving(true);
@@ -123,7 +127,12 @@ export function AgentInstructionsSettings() {
<span className="truncate"> <span className="truncate">
{t("agentInstructions.loadFailed", { message: loadError })} {t("agentInstructions.loadFailed", { message: loadError })}
</span> </span>
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}> <button
type="button"
className="btn-ghost shrink-0 text-xs"
onClick={retry}
disabled={retrying}
>
{t("common:retry")} {t("common:retry")}
</button> </button>
</p> </p>
@@ -29,6 +29,7 @@ import { AiToolIcon } from "../AiToolIcon";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { api, type AiTool, type AiToolJob } from "../../lib/api"; import { api, type AiTool, type AiToolJob } from "../../lib/api";
import { usePermissions } from "../AuthGate"; import { usePermissions } from "../AuthGate";
import { copyText } from "../../lib/clipboard";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" }; type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
@@ -374,9 +375,7 @@ export function AiToolsSettings({
{ctx.menu.item.path && ( {ctx.menu.item.path && (
<> <>
{isAdmin && <MenuSeparator />} {isAdmin && <MenuSeparator />}
<MenuItem <MenuItem onSelect={pick((tool) => void copyText(tool.path ?? ""))}>
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
>
{t("rowMenu.copyPath")} {t("rowMenu.copyPath")}
</MenuItem> </MenuItem>
</> </>
@@ -4,10 +4,13 @@
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface * one card holds one subject: **Text & fonts** (`#fonts`) — the interface
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`, * language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
* issue #79), the terminal font and its size (`lib/terminalFont.ts`, * issue #79), the terminal font and its size (`lib/terminalFont.ts`,
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) — * `#terminal-font`, with a preview line), and — local mode only — whether a
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the * the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`, * session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`); then the * `#workspace-tabs`), and whether a Markdown file opens as Preview (the
* default) or Raw (`lib/markdownView.ts`, `#markdown-view`); then the
* Dark / Light / System mode switch and one named-theme picker per mode, and the * Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet * corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in * pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
@@ -37,6 +40,9 @@ import {
type TerminalFont, type TerminalFont,
} from "../../lib/terminalFont"; } from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs"; import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { import {
TERMINAL_FONT_SIZES, TERMINAL_FONT_SIZES,
UI_FONT_SIZES, UI_FONT_SIZES,
@@ -97,6 +103,9 @@ export function AppearanceSettings() {
const [termSize, setTermSize] = useState(readTerminalFontSize); const [termSize, setTermSize] = useState(readTerminalFontSize);
const [uiSize, setUiSize] = useState(readUiFontSize); const [uiSize, setUiSize] = useState(readUiFontSize);
const [allTabs, setAllTabs] = useState(readAllTabs); const [allTabs, setAllTabs] = useState(readAllTabs);
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
const [nativePaste, setNativePaste] = useState(readNativePaste);
const { local } = usePermissions();
const pickTermFont = (font: TerminalFont) => { const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font); writeTerminalFont(font);
setTermFont(font); setTermFont(font);
@@ -206,6 +215,18 @@ export function AppearanceSettings() {
> >
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1| ❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p> </p>
{local && (
<SettingsSwitchRow
id="native-paste"
label={t("nativePaste.label")}
description={t("nativePaste.description")}
checked={nativePaste}
onChange={(on) => {
writeNativePaste(on);
setNativePaste(on);
}}
/>
)}
</SettingsSection> </SettingsSection>
<SettingsSection <SettingsSection
@@ -224,6 +245,25 @@ export function AppearanceSettings() {
setAllTabs(on); setAllTabs(on);
}} }}
/> />
<SettingsRow
id="markdown-view"
label={t("markdownView.label")}
description={t("markdownView.description")}
control={
<SettingsSegmented
ariaLabel={t("markdownView.label")}
value={mdPreview ? "preview" : "raw"}
onChange={(v) => {
writeMarkdownPreview(v === "preview");
setMdPreview(v === "preview");
}}
options={[
{ value: "preview" as const, label: t("markdownView.preview") },
{ value: "raw" as const, label: t("markdownView.raw") },
]}
/>
}
/>
</SettingsSection> </SettingsSection>
<section id="theme" className="card p-5 space-y-5 scroll-mt-24"> <section id="theme" className="card p-5 space-y-5 scroll-mt-24">
@@ -0,0 +1,26 @@
/**
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
* workspace folder that holds several git repositories shows each of them as
* its own section in the Changes panel (off by default: only the folder's own
* git). Writes the account preference in `lib/innerReposPref.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChangesPanelSettings() {
const { t } = useTranslation("settings");
const mode = useInnerReposMode();
return (
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
<SettingsSwitchRow
label={t("changesPanel.innerRepos")}
description={t("changesPanel.innerReposDesc")}
checked={mode === "all"}
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
/>
</SettingsSection>
);
}
@@ -14,7 +14,9 @@
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made * SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`), * 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 * 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 * kind of host, then the other hosts; a named key is a card of its own under
* its type's default one, with the same steps, and "Add another key" makes
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS * a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials * 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 * table above it, so this lists them only for a member. The private key and
@@ -24,17 +26,7 @@
*/ */
import { useCallback, useEffect, useState, type ReactNode } from "react"; import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
Cloud,
Copy,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api"; import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
@@ -87,26 +79,20 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
); );
} }
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]; type Key = NonNullable<GitIdentity["sshKeys"]>[number];
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
/** /**
* One kind of git host and the key it takes: no key → a Generate button; * One SSH key and the steps of the host it is for: no key → a Generate
* a key → step 1 (copy its public half) followed by the host's own steps. * button; a key → step 1 (copy its public half) followed by the host's own
* steps. The default key of a type and every named one share it.
*/ */
function KeyCard({ function KeyCard({
title, title,
sub, sub,
type, label,
k, k,
id, writable,
busy, busy,
onGenerate, onGenerate,
onRemove, onRemove,
@@ -114,12 +100,13 @@ function KeyCard({
}: { }: {
title: string; title: string;
sub: string; sub: string;
type: "ed25519" | "rsa"; /** What names the public key field ("RSA", "ED25519" or the key's name). */
label: string;
k: Key | undefined; k: Key | undefined;
id: GitIdentity; writable: boolean;
busy: boolean; busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>; onGenerate: () => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>; onRemove: () => Promise<void>;
children: ReactNode; children: ReactNode;
}) { }) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@@ -130,23 +117,19 @@ function KeyCard({
<div className="text-xs font-medium text-fg-primary">{title}</div> <div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div> <div className="text-[10px] text-fg-muted">{sub}</div>
</div> </div>
{id.writable && ( {writable && (
<button <button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
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 ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")} {k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button> </button>
)} )}
{k && id.writable && ( {k && writable && (
<button <button
type="button" type="button"
className={`${BTN} hover:text-status-danger`} className={`${BTN} hover:text-status-danger`}
disabled={busy} disabled={busy}
onClick={() => void onRemove(type)} aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
onClick={() => void onRemove()}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")} {t("gitIdentity.remove")}
@@ -165,9 +148,9 @@ function KeyCard({
</div> </div>
<textarea <textarea
readOnly readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`} aria-label={`${t("gitIdentity.publicKey")} ${label}`}
value={k.publicKey} value={k.publicKey}
rows={type === "rsa" ? 3 : 2} rows={k.type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()} onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`} className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/> />
@@ -182,6 +165,104 @@ function KeyCard({
); );
} }
/**
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
* default key's login is the region's plain host; a named key's is saved
* under an account name (its own name unless another is typed), tied to it.
*/
function AwsLoginForm({
busy,
named,
onSave,
}: {
busy: boolean;
/** The named key's name; undefined for the default key. */
named?: string;
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
return (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={v.keyId}
onChange={(e) => setV({ ...v, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={v.region}
onChange={(e) => setV({ ...v, 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={named ?? t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={v.alias}
onChange={(e) => setV({ ...v, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
/**
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
* offers only this key — the default key would log in as the other account.
*/
function GitHostForm({
busy,
onSave,
}: {
busy: boolean;
onSave: (host: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [host, setHost] = useState("github.com");
return (
<form
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(host.trim());
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="github.com"
autoComplete="off"
value={host}
onChange={(e) => setHost(e.target.value)}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
export function GitIdentitySettings() { export function GitIdentitySettings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null); const [id, setId] = useState<GitIdentity | null>(null);
@@ -229,19 +310,42 @@ export function GitIdentitySettings() {
const keyOf = (type: "ed25519" | "rsa") => const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name); keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name); const named = keys.filter((k) => k.name);
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
const keyLabel = (keyId: string | null | undefined) => { const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined; const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey"); if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd")); return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
}; };
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null); // "Add another key" in a tab makes the next key of that tab's type at once,
const makeNamed = async () => { // named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
if (!newKey) return; // account name of the user's own goes in its step 3.
const ok = await run( const makeNamed = (type: "ed25519" | "rsa") => {
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()), const prefix = type === "rsa" ? "aws" : "git";
let n = 2;
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
return run(
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
t("gitIdentity.keyMade")
);
};
const addKey = (type: "ed25519" | "rsa") => (
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.addKey")}
</button>
);
const replaceNamed = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
t("gitIdentity.keyMade") t("gitIdentity.keyMade")
); );
if (ok) setNewKey(null);
}; };
const removeNamed = async (keyId: string) => { const removeNamed = async (keyId: string) => {
if ( if (
@@ -304,29 +408,60 @@ export function GitIdentitySettings() {
return m ? [{ ...r, region: m[1] ?? "" }] : []; return m ? [{ ...r, region: m[1] ?? "" }] : [];
}); });
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host)); const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" }); // A login is shown under the key it names; one naming no key (or one gone)
const saveAws = async () => { // under the default key.
let user = aws.keyId.trim(); const namedIds = new Set(named.map((k) => k.id));
let host = `git-codecommit.${aws.region}.amazonaws.com`; const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
let user = v.keyId.trim();
let host = `git-codecommit.${v.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both. // A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user); const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) { if (m?.[1] && m[2]) {
user = m[1]; user = m[1];
host = m[2].toLowerCase(); host = m[2].toLowerCase();
} }
const alias = aws.alias.trim().toLowerCase(); // A named key's account goes by the key's name unless another is typed.
const ok = await run( const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
return run(
() => () =>
api.gitIdentity.setSshUser({ api.gitIdentity.setSshUser({
host, host,
user, user,
...(alias ? { alias } : {}), ...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}), ...(k?.id ? { key: k.id } : {}),
}), }),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) }) t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
); );
if (ok) setAws({ ...aws, keyId: "", alias: "" });
}; };
const saveGitHost = (host: string, k: Key) => {
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
return run(
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
t("gitIdentity.otherSaved", { host: k.name })
);
};
/** The logins of one key, each with its delete button. */
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
rows.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">
{text(r)}
</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>
));
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" }); const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => { const saveOther = async () => {
let host = other.host.trim(); let host = other.host.trim();
@@ -346,7 +481,7 @@ export function GitIdentitySettings() {
if (ok) setOther({ host: "", user: "", alias: "", key: "" }); if (ok) setOther({ host: "", user: "", alias: "", key: "" });
}; };
/** The key picker of a login form: every key, or one. */ /** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => ( const keySelect = (value: string, onChange: (v: string) => void) => (
<select <select
aria-label={t("gitIdentity.loginKey")} aria-label={t("gitIdentity.loginKey")}
value={value} value={value}
@@ -355,7 +490,7 @@ export function GitIdentitySettings() {
> >
<option value="">{t("gitIdentity.anyKey")}</option> <option value="">{t("gitIdentity.anyKey")}</option>
{keys {keys
.filter((k) => k.id && (!only || k.type === only)) .filter((k) => k.id)
.map((k) => ( .map((k) => (
<option key={k.id} value={k.id}> <option key={k.id} value={k.id}>
{keyLabel(k.id)} {keyLabel(k.id)}
@@ -396,17 +531,6 @@ export function GitIdentitySettings() {
const sshTabs = [ const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch }, { id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud }, { 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.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }] ? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []), : []),
@@ -498,228 +622,97 @@ export function GitIdentitySettings() {
onSelect={setSshTab} onSelect={setSshTab}
> >
{activeSshTab === "git" && ( {activeSshTab === "git" && (
<KeyCard <div className="space-y-2">
title={t("gitIdentity.gitHubTitle")} <KeyCard
sub={t("gitIdentity.gitHubSub")} title={t("gitIdentity.gitHubTitle")}
type="ed25519" sub={t("gitIdentity.gitHubSub")}
k={keyOf("ed25519")} label="ED25519"
id={id} k={keyOf("ed25519")}
busy={busy} writable={id.writable}
onGenerate={generate} busy={busy}
onRemove={removeKey} onGenerate={() => generate("ed25519")}
> onRemove={() => removeKey("ed25519")}
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step> >
<Step n={3}> <Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
{t("gitIdentity.gitHubStepClone")} </KeyCard>
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code> {namedOf("ed25519").map((k) => {
</Step> const rows = rowsOf(otherRows, k);
</KeyCard> return (
<KeyCard
key={k.id}
title={k.name!}
sub={t("gitIdentity.namedGitSub")}
label={k.name!}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => replaceNamed(k)}
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
{id.writable && rows.length === 0 && (
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
)}
</Step>
</KeyCard>
);
})}
{id.writable &&
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
addKey("ed25519")}
</div>
)} )}
{activeSshTab === "aws" && ( {activeSshTab === "aws" && (
<KeyCard <div className="space-y-2">
title={t("gitIdentity.awsTitle")} {[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
sub={t("gitIdentity.awsSub")} const isNamed = i > 0 && !!k;
type="rsa" const rows = rowsOf(awsRows, isNamed ? k : undefined);
k={keyOf("rsa")} return (
id={id} <KeyCard
busy={busy} key={k?.id ?? "id_rsa"}
onGenerate={generate} title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
onRemove={removeKey} sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
> label={isNamed ? k!.name! : "RSA"}
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step> k={k}
<Step n={3}> writable={id.writable}
{t("gitIdentity.awsStepKeyId")} busy={busy}
{id.sshUsers === undefined ? null : ( onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
<> onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
{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" /> <Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
{t("gitIdentity.newKey")} <Step n={3}>
</button> {t("gitIdentity.awsStepKeyId")}
)} {id.sshUsers === undefined ? null : (
</div> <>
{newKey && ( {loginRows(
<form rows,
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]" (r) =>
onSubmit={(e) => { `${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
e.preventDefault(); )}
void makeNamed(); {id.writable && (
}} <AwsLoginForm
> busy={busy}
<input named={isNamed ? k!.name! : undefined}
aria-label={t("gitIdentity.keyName")} onSave={(v) => saveAws(v, isNamed ? k : undefined)}
placeholder={t("gitIdentity.keyNamePlaceholder")} />
autoComplete="off" )}
autoFocus </>
value={newKey.name} )}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })} </Step>
className={`${INPUT} font-mono`} </KeyCard>
/> );
<select })}
aria-label={t("gitIdentity.keyType")} {id.writable && keys.some((k) => k.type === "rsa") && (
value={newKey.type} <>
onChange={(e) => {addKey("rsa")}
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" }) <div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
} </>
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>
<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" && ( {activeSshTab === "other" && (
@@ -0,0 +1,242 @@
/**
* @file NineRouterUpdate.tsx
* @description **Update 9router** on the AI Proxy page (an admin's; beside
* Config 9router): a button — dotted when npm has a newer 9router — and a
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
* again hidden (tray, no browser) — the last only when it was running before.
* The dialog shows why an update cannot run right now (the server's `blocker`,
* with the command to run by hand when sudo needs a password) and follows the
* job by polling until it ends. Closing the dialog does not stop the job.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
const POLL_MS = 1000;
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
}
export function NineRouterUpdate() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
const [open, setOpen] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useDialog(open, dialogRef, () => setOpen(false));
const load = useCallback(async (fresh = false) => {
try {
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const running = status?.job?.status === "running";
// Follow a running job — also one started from another tab.
useEffect(() => {
if (!running) return;
const timer = window.setInterval(() => void load(), POLL_MS);
return () => window.clearInterval(timer);
}, [running, load]);
const openDialog = () => {
setError(null);
setOpen(true);
void load(true);
};
const start = async () => {
setStarting(true);
setError(null);
try {
const job = await api.aiProxies.nineRouterUpdate();
setStatus((s) => (s ? { ...s, job } : s));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
void load(true);
} finally {
setStarting(false);
}
};
if (!status?.installed) return null;
const job = status.job;
const blocker = status.blocker;
return (
<>
<button
type="button"
onClick={openDialog}
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUpCircle className="h-3.5 w-3.5" />
)}
{t("aiProxy.update9router")}
{status.updateAvailable && !running && (
<span
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
aria-label={t("aiProxy.update9routerAvailable")}
/>
)}
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="nine-router-update-title"
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<h3
id="nine-router-update-title"
className="mb-3 text-sm font-semibold text-fg-primary"
>
{t("aiProxy.update9router")}
</h3>
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
<dd className="text-fg-primary">
{status.running
? status.managedBy === "systemd"
? t("aiProxy.update9routerRunningUnit")
: t("aiProxy.update9routerRunning")
: t("aiProxy.update9routerStopped")}
</dd>
</dl>
<p className="mb-3 text-[11px] text-fg-muted">
{status.running
? t("aiProxy.update9routerExplain")
: t("aiProxy.update9routerExplainStopped")}
</p>
{job && (
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
{job.steps.map((s) => (
<li key={s.id} className="flex items-start gap-2 text-xs">
<span className="mt-0.5">
<StepIcon status={s.status} />
</span>
<span className="min-w-0">
<span className="text-fg-primary">
{t(`aiProxy.update9routerStep.${s.id}`)}
</span>
{s.message && (
<span className="block break-words text-[11px] text-fg-muted">
{s.message}
</span>
)}
</span>
</li>
))}
</ol>
)}
{job && job.status !== "running" && (
<p
className={`mb-3 text-xs ${
job.status === "done" ? "text-status-success" : "text-status-danger"
}`}
>
{job.status !== "done"
? t("aiProxy.update9routerFailed")
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
</p>
)}
{job?.log && (
<details className="mb-3">
<summary className="cursor-pointer text-[11px] text-fg-muted">
{t("aiProxy.update9routerLog")}
</summary>
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
{job.log}
</pre>
</details>
)}
{blocker && !running && blocker.code !== "EUPTODATE" && (
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
<p>{blocker.message}</p>
{blocker.command && (
<div className="mt-2 flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
{blocker.command}
</code>
<button
type="button"
onClick={() => void copyText(blocker.command!)}
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
aria-label={t("aiProxy.update9routerCopy")}
title={t("aiProxy.update9routerCopy")}
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)}
{blocker?.code === "EUPTODATE" && !running && (
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
)}
{error && (
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
>
{t("aiProxy.update9routerClose")}
</button>
<button
type="button"
onClick={() => void start()}
disabled={Boolean(blocker) || running || starting}
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
>
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t("aiProxy.update9routerStart")}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,68 @@
/**
* @file OmpSharingSettings — Settings → AI tools → Oh My Pi login sharing: the
* admin's server-wide switch that lets every member's omp pane run on the
* HOST account's omp login through omp's own auth broker + gateway, without
* any member holding a credential (`GET/PUT /api/ai-tools/omp-sharing`,
* `server/lib/omp-share.js`). Renders nothing outside server mode with
* per-user accounts (S3), or for anyone but an admin.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type OmpSharingState } from "../../lib/api";
import { toast } from "../../lib/toast";
import { usePermissions } from "../AuthGate";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function OmpSharingSettings() {
const { t } = useTranslation("settings");
const { isAdmin, local } = usePermissions();
const [state, setState] = useState<OmpSharingState | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (local || !isAdmin) return;
api.aiTools
.ompSharing()
.then(setState)
.catch(() => setState(null));
}, [local, isAdmin]);
if (local || !isAdmin || !state?.available) return null;
const error = state.broker.error || state.gateway.error;
const status = !state.enabled
? null
: !state.installed
? t("ompSharing.notInstalled")
: state.broker.running && state.gateway.running
? t("ompSharing.running")
: error
? t("ompSharing.failed", { error })
: t("ompSharing.starting");
return (
<SettingsSection id="omp-sharing" title={t("ompSharing.title")}>
<SettingsSwitchRow
label={t("ompSharing.switch")}
description={t("ompSharing.hint")}
checked={state.enabled}
disabled={busy}
onChange={async (on) => {
setBusy(true);
try {
setState(await api.aiTools.setOmpSharing(on));
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
/>
{status && (
<p data-testid="omp-sharing-status" className="text-xs text-fg-muted">
{status}
</p>
)}
</SettingsSection>
);
}
@@ -16,8 +16,7 @@ import { api } from "../../lib/api";
import type { PushTokenStatus } from "../../lib/api"; import type { PushTokenStatus } from "../../lib/api";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { SettingsSection } from "./SettingsRow"; import { SettingsSection } from "./SettingsRow";
import { copyText } from "../../lib/clipboard";
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */ /** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
function forwarder(origin: string) { function forwarder(origin: string) {
@@ -0,0 +1,154 @@
/**
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
* account's own starting choices for a new Claude Code session (permission
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
* synced per account). Turning bypass on as a default asks first; a pick in
* the prompt box still changes only that one session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert } from "lucide-react";
import type { CodexApproval, CodexSandbox } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { confirmDialog } from "../../lib/confirm";
import {
CODEX_APPROVALS,
CODEX_SANDBOXES,
DEFAULT_MODES,
readCodexDefaults,
readDefaultMode,
writeCodexDefaults,
writeDefaultMode,
type DefaultMode,
} from "../../lib/sessionDefaults";
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
const MODE_KEYS: Record<DefaultMode, string> = {
auto: "Auto",
acceptEdits: "AcceptEdits",
default: "Default",
plan: "Plan",
bypassPermissions: "Bypass",
};
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
"read-only": "sandboxReadOnly",
"workspace-write": "sandboxWorkspaceWrite",
"danger-full-access": "sandboxFullAccess",
};
const APPROVAL_KEYS: Record<CodexApproval, string> = {
"on-request": "approvalOnRequest",
never: "approvalNever",
};
function Danger({ children }: { children: string }) {
return (
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{children}
</p>
);
}
export function SessionDefaultsSettings() {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
const [codex, setCodex] = useState(readCodexDefaults);
const askBypass = (tool: string) =>
confirmDialog({
title:
tool === "Codex"
? t("sessionDefaults.codexConfirmTitle")
: t("sessionDefaults.confirmTitle", { tool }),
message: t("sessionDefaults.confirmBody", { tool }),
confirmLabel: t("sessionDefaults.confirmLabel"),
});
const pickMode = async (m: DefaultMode) => {
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
writeDefaultMode(m);
setMode(m);
};
const patchCodex = (patch: Partial<typeof codex>) => {
writeCodexDefaults(patch);
setCodex((c) => ({ ...c, ...patch }));
};
const pickCodexBypass = async (on: boolean) => {
if (on && !(await askBypass("Codex"))) return;
patchCodex({ bypass: on });
};
if (!shown("claude") && !shown("codex")) return null;
return (
<SettingsSection
id="session-defaults"
title={t("sessionDefaults.title")}
description={t("sessionDefaults.description")}
>
{shown("claude") && (
<>
<SettingsRow
label={t("sessionDefaults.claudeMode")}
description={t("sessionDefaults.claudeModeDesc")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.claudeMode")}
value={mode}
onChange={(m) => void pickMode(m)}
options={DEFAULT_MODES.map((m) => ({
value: m,
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
}))}
/>
}
/>
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
</>
)}
{shown("codex") && (
<>
<SettingsRow
label={t("sessionDefaults.codexSandbox")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexSandbox")}
value={codex.sandbox}
onChange={(sandbox) => patchCodex({ sandbox })}
options={CODEX_SANDBOXES.map((v) => ({
value: v,
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
}))}
/>
}
/>
<SettingsRow
label={t("sessionDefaults.codexApproval")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexApproval")}
value={codex.approval}
onChange={(approval) => patchCodex({ approval })}
options={CODEX_APPROVALS.map((v) => ({
value: v,
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
}))}
/>
}
/>
<SettingsSwitchRow
label={t("sessionDefaults.codexBypass")}
description={t("sessionDefaults.codexBypassDesc")}
checked={codex.bypass}
onChange={(on) => void pickCodexBypass(on)}
/>
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
</>
)}
</SettingsSection>
);
}
@@ -19,6 +19,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
import { CopyButton } from "../event-views/primitives"; import { CopyButton } from "../event-views/primitives";
import { snapshotUrl } from "../SnapshotButton"; import { snapshotUrl } from "../SnapshotButton";
import { SettingsSection } from "./SettingsRow"; import { SettingsSection } from "./SettingsRow";
import { copyText } from "../../lib/clipboard";
export function SnapshotSettings() { export function SnapshotSettings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@@ -116,9 +117,7 @@ export function SnapshotSettings() {
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}> <MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
{t("snapshots.open")} {t("snapshots.open")}
</MenuItem> </MenuItem>
<MenuItem <MenuItem onSelect={pick((s) => void copyText(snapshotUrl(s.token)))}>
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
>
{t("snapshots.copyLink")} {t("snapshots.copyLink")}
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
@@ -0,0 +1,808 @@
/**
* @file ToolProfilesSettings — Settings → AI tools → Profiles: every AI tool's
* login profiles in ONE place, a tab per tool. Claude Code's are cc-profiles
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
* warning — this panel used to be a modal on the Claude Code page); Codex and
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
* each Claude/Codex profile to the account pool in Administration → Users,
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
* pool's Add dialog on it), and shows how many accounts the pool holds.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
AlertCircle,
ArrowRightLeft,
Check,
Copy,
KeyRound,
Pencil,
Save,
ShieldAlert,
Terminal,
Trash2,
} from "lucide-react";
import {
api,
type AccountPool,
type CcAccountIdentity,
type CcProfile,
type CcProfileUsage,
type CcUsageWindow,
type ToolProfile,
type ToolProfileIdentity,
type ToolProfilesTool,
} from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { useAiTools } from "../../hooks/useAiTools";
import { usePermissions } from "../AuthGate";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { SkeletonRows } from "../config/configPrimitives";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsSection } from "./SettingsRow";
/** The tools the panel covers, in tab order, with the command that signs one in. */
const TOOLS: { id: string; label: string; login: string }[] = [
{ id: "claude", label: "Claude Code", login: "claude auth login" },
{ id: "codex", label: "Codex", login: "codex login" },
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
{ id: "antigravity", label: "Antigravity", login: "agy" },
];
/** Tools the account pool takes (server/routes/accounts.js). */
const POOL_TOOLS = new Set(["claude", "codex"]);
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
const btnCls =
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
export function ToolProfilesSettings() {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const { local, seesAll } = usePermissions();
const shown = TOOLS.filter((x) => {
const tool = aiTools.byId(x.id);
if (x.id === "claude") return tool?.enabled !== false;
return !!tool && tool.installed && tool.enabled !== false;
});
const [tab, setTab] = useState<string>("claude");
const current = shown.find((x) => x.id === tab) ?? shown[0];
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
const [pool, setPool] = useState<AccountPool | null>(null);
const loadOthers = useCallback(
() =>
api.toolProfiles
.list()
.then((r) => setOthers(r.tools))
.catch(() => setOthers([])),
[]
);
useEffect(() => {
void loadOthers();
}, [loadOthers]);
// The pool is a server-mode, staff-only read.
useEffect(() => {
if (local || !seesAll) return;
api.accountPool
.list()
.then(setPool)
.catch(() => setPool(null));
}, [local, seesAll]);
if (!current) return null;
const poolCount = (tool: string) =>
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
return (
<SettingsSection
id="tool-profiles"
title={t("toolProfiles.title")}
description={t("toolProfiles.description")}
>
<div role="tablist" aria-label={t("toolProfiles.title")} className="flex flex-wrap gap-1.5">
{shown.map((x) => (
<button
key={x.id}
type="button"
role="tab"
aria-selected={x.id === current.id}
onClick={() => setTab(x.id)}
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs ${
x.id === current.id
? "border-accent/40 bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
}`}
>
<AiToolIcon tool={{ id: x.id, label: x.label }} className="h-3.5 w-3.5" />
{x.label}
</button>
))}
</div>
<div role="tabpanel" className="mt-3 space-y-3">
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
<PoolLine count={poolCount(current.id)!} />
)}
{current.id === "claude" ? (
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
) : (
<OtherProfiles
key={current.id}
tool={current.id}
label={current.label}
login={current.login}
state={others?.find((o) => o.tool === current.id) ?? null}
loading={others === null}
pooled={!local && POOL_TOOLS.has(current.id)}
onChanged={loadOthers}
/>
)}
</div>
</SettingsSection>
);
}
/** "The pool holds N accounts" + the way to it (Administration → Users). */
function PoolLine({ count }: { count: number }) {
const { t } = useTranslation("settings");
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-fg-secondary">
<KeyRound className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("toolProfiles.poolCount", { count })}</span>
<Link to="/admin#account-pool" className="text-accent hover:underline">
{t("toolProfiles.openPool")}
</Link>
</div>
);
}
/** The link that opens the pool's Add dialog on one saved profile (admins only). */
function MoveToPool({ tool, name }: { tool: string; name: string }) {
const { t } = useTranslation("settings");
const { isAdmin } = usePermissions();
if (!isAdmin) return null;
return (
<Link
to={`/admin?${new URLSearchParams({ poolAdd: tool, poolProfile: name })}#account-pool`}
title={t("toolProfiles.moveToPoolHint")}
className="h-7 text-[11px] px-2 rounded-md text-fg-muted hover:text-accent hover:bg-surface-3 inline-flex items-center gap-1"
>
<ArrowRightLeft className="h-3 w-3" />
{t("toolProfiles.moveToPool")}
</Link>
);
}
function LoginHint({ text, command }: { text: string; command: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<div>
<div className="text-[10px] text-fg-muted mb-1">{text}</div>
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
<button
type="button"
onClick={async () => {
if (await copyText(command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
</div>
);
}
/** A profile's label, renamed in place: click it (or the menu's Rename), type, Enter. */
function LabelEdit({
label,
busy,
editing,
setEditing,
onRename,
}: {
label: string;
busy: boolean;
editing: boolean;
setEditing: (on: boolean) => void;
onRename: (next: string) => void;
}) {
const { t } = useTranslation("ccConfig");
const [draft, setDraft] = useState(label);
useEffect(() => {
if (editing) setDraft(label);
}, [editing, label]);
if (editing) {
return (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => setEditing(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditing(false);
if (e.key !== "Enter") return;
const next = draft.trim();
setEditing(false);
if (next && next !== label) onRename(next);
}}
maxLength={60}
aria-label={t("profiles.renameLabel")}
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
);
}
return (
<button
disabled={busy}
onClick={() => setEditing(true)}
title={t("profiles.renameHint")}
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
>
{label}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
</button>
);
}
/** One mutation with a busy key, a toast either way, then a reload. */
function useRunner(load: () => Promise<unknown>) {
const [busy, setBusy] = useState<string | null>(null);
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => {
setBusy(key);
try {
await fn();
toast.success(success);
await load();
} catch (err: unknown) {
toast.error(err);
} finally {
setBusy(null);
}
},
[load]
);
return { busy, run };
}
// ── Codex, Oh My Pi, … ─────────────────────────────────────────────────
function OtherProfiles({
tool,
label,
login,
state,
loading,
pooled,
onChanged,
}: {
tool: string;
label: string;
login: string;
state: ToolProfilesTool | null;
loading: boolean;
pooled: boolean;
onChanged: () => Promise<unknown>;
}) {
const { t } = useTranslation("settings");
const { t: tc } = useTranslation("ccConfig");
const { busy, run } = useRunner(onChanged);
const [renaming, setRenaming] = useState<string | null>(null);
if (loading) return <SkeletonRows n={2} />;
if (!state) return <p className="text-xs text-fg-muted">{t("toolProfiles.noProfiles")}</p>;
if (!state.supported) {
return (
<p className="text-xs text-fg-muted inline-flex items-start gap-1.5">
<ShieldAlert className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{t(`toolProfiles.unsupported.${tool}`, { defaultValue: state.reason ?? "" })}</span>
</p>
);
}
const items = state.items;
const activeSaved = items.some((p) => p.active);
const remove = async (p: ToolProfile) => {
const ok = await confirmDialog({
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
message: t("toolProfiles.deleteConfirmBody", { tool: label }),
confirmLabel: tc("profiles.deleteButton"),
});
if (ok) {
void run(
`del:${p.name}`,
() => api.toolProfiles.remove(tool, p.name),
tc("profiles.deleteSuccess", { name: p.label })
);
}
};
return (
<div className="space-y-3">
<p className="text-[11px] text-fg-muted leading-relaxed">
{t(`toolProfiles.subtitle.${tool}`, { defaultValue: "" })}
</p>
{state.error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{state.error.message}
</div>
)}
<div className={`${rowCls} space-y-1`}>
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{tc("profiles.activeLabel")}
</div>
{state.active ? (
<>
<div className="text-sm text-fg-primary">{who(state.active)}</div>
<OtherIdentityLine identity={state.active} />
</>
) : (
<div className="text-sm text-fg-muted">{t("toolProfiles.noActive", { tool: label })}</div>
)}
{state.active && !activeSaved && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{tc("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
void run("save", () => api.toolProfiles.save(tool), tc("profiles.saveSuccess"))
}
className={btnCls}
>
<Save className="w-3 h-3" />
{busy === "save" ? tc("profiles.saving") : t("toolProfiles.saveButton")}
</button>
</div>
)}
</div>
{items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{tc("profiles.empty")}
</div>
)}
{items.map((p) => (
<div key={p.name} data-testid={`profile-row-${tool}-${p.name}`} className={rowCls}>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.toolProfiles.rename(tool, p.name, next),
tc("profiles.renameSuccess", { name: next })
)
}
/>
{p.active && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">{who(p)}</span>
{!p.active && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`switch:${p.name}`,
() => api.toolProfiles.activate(tool, p.name),
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
)
}
className={btnCls}
>
{busy === `switch:${p.name}`
? tc("profiles.switching")
: tc("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool={tool} name={p.name} />}
{!p.active && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={tc("profiles.deleteButton")}
aria-label={tc("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<OtherIdentityLine identity={p} />
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{tc("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
</div>
);
}
const who = (id: ToolProfileIdentity) => id.email || id.detail || id.kind;
function OtherIdentityLine({ identity }: { identity: ToolProfileIdentity }) {
const { t } = useTranslation("settings");
const parts = [
t(`toolProfiles.kind.${identity.kind}`, { defaultValue: identity.kind }),
identity.email ? identity.detail : null,
].filter(Boolean);
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
function ActiveBadge() {
const { t } = useTranslation("ccConfig");
return (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
{t("profiles.activeBadge")}
</span>
);
}
// ── Claude Code ────────────────────────────────────────────────────────
//
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
// subagents, memory, plugins, marketplaces and settings all stay exactly where
// they are. The dashboard cannot log a new account in (only `claude auth login`
// can mint tokens), so adding a profile is always "log in, then snapshot what
// the CLI wrote" — a save button on the live account, never a name field: the
// server derives the identifier, and the user edits the label in place. The
// save button only appears while the live account is unsaved, because one
// account is one profile.
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
/** Quota per profile, keyed by `name`, fetched for every profile once the
* list arrives: the live account is read from the CLI and never refreshed,
* any other profile only when its token has actually expired. */
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
/** Per profile, so one slow account does not freeze the other rows. */
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const ctx = useContextMenu<CcProfile>();
const load = useCallback(async () => {
try {
const r = await api.ccConfig.profiles();
setItems(r.items);
setActive(r.active);
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
}
}, []);
const { busy, run } = useRunner(load);
useEffect(() => {
void load();
}, [load]);
const checkUsage = useCallback(async (name: string) => {
setUsageBusy((prev) => ({ ...prev, [name]: true }));
setUsageError((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
try {
const result = await api.ccConfig.profileUsage(name);
setUsage((prev) => ({ ...prev, [name]: result }));
} catch (err: unknown) {
setUsageError((prev) => ({
...prev,
[name]: err instanceof Error ? err.message : "unknown error",
}));
} finally {
setUsageBusy((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}
}, []);
// Every profile's quota, once per profile per mount: a retry is the row's
// own button, so a failed account is not re-asked on every render.
useEffect(() => {
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage]);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
const copies = (p: CcProfile) =>
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
/** Deletable unless it is the ONLY saved copy of the live account. */
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
const [renaming, setRenaming] = useState<string | null>(null);
const switchTo = (p: CcProfile) =>
void run(
`switch:${p.name}`,
() => api.ccConfig.activateProfile(p.name),
t("profiles.switchSuccess", { name: p.label })
);
const remove = async (p: CcProfile) => {
const ok = await confirmDialog({
title: t("profiles.deleteConfirmTitle", { name: p.label }),
message: t("profiles.deleteConfirmBody"),
confirmLabel: t("profiles.deleteButton"),
});
if (!ok) return;
void run(
`del:${p.name}`,
() => api.ccConfig.deleteProfile(p.name),
t("profiles.deleteSuccess", { name: p.label })
);
};
const pick = (fn: (p: CcProfile) => void) => () => {
const p = ctx.menu!.item;
ctx.close();
fn(p);
};
return (
<div className="space-y-3">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
{mismatchEmail && (
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
)}
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<div className={`${rowCls} space-y-1`}>
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("profiles.activeLabel")}
</div>
{active ? (
<>
<div className="text-sm text-fg-primary">
{active.email || active.displayName || active.accountUuid}
</div>
<IdentityLine identity={active} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
}
className={btnCls}
>
<Save className="w-3 h-3" />
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
</button>
</div>
)}
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("profiles.empty")}
</div>
)}
{items?.map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
className={rowCls}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
/>
{p.active && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
{!ctx.menu.item.active && (
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
{t("profiles.switchButton")}
</MenuItem>
)}
<MenuItem
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
onSelect={pick((p) => void checkUsage(p.name))}
>
{t("profiles.usageCheck")}
</MenuItem>
{deletable(ctx.menu.item) && (
<>
<MenuSeparator />
<MenuItem
danger
disabled={busy !== null}
testId="profile-menu-delete"
onSelect={pick((p) => void remove(p))}
>
{t("profiles.deleteButton")}
</MenuItem>
</>
)}
</ContextMenu>
)}
<LoginHint text={t("profiles.loginHint")} command="claude auth login" />
</div>
);
}
/**
* One profile's quota: a button until asked, then the two windows Anthropic
* reports — the 5-hour session allowance and the 7-day one. Reading a saved
* profile's usage may refresh its OAuth token (rotating it on disk), so it
* happens once per mount or click, never on a poll.
*/
function ProfileUsageLine({
usage,
error,
busy,
disabled,
onCheck,
}: {
usage?: CcProfileUsage;
error?: string;
busy: boolean;
disabled: boolean;
onCheck: () => void;
}) {
const { t } = useTranslation("ccConfig");
const pct = (w: CcUsageWindow | null | undefined) =>
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
const resetTitle = (w: CcUsageWindow | null | undefined) =>
w && w.resetsAt
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
: undefined;
if (error) {
return (
<div className="mt-1 flex items-center gap-2 text-[10px]">
<span className="text-status-danger">{error}</span>
<button
onClick={onCheck}
disabled={disabled}
className="text-fg-muted hover:text-accent disabled:opacity-50"
>
{t("profiles.usageRetry")}
</button>
</div>
);
}
if (!usage) {
return (
<button
onClick={onCheck}
disabled={disabled}
title={t("profiles.usageHint")}
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
</button>
);
}
return (
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
<span title={resetTitle(usage.fiveHour)}>
{t("profiles.usageFiveHour")}{" "}
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
</span>
<span title={resetTitle(usage.sevenDay)}>
{t("profiles.usageSevenDay")}{" "}
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
</span>
<button
onClick={onCheck}
disabled={disabled}
className="hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
</button>
</div>
);
}
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
Boolean
);
if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
@@ -2,8 +2,9 @@
* @file AccountSettings.test.tsx * @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves * @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new * only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, asks before signing * passwords differ, keeps a password that misses a rule from being sent,
* the other devices out, and uploads/removes the avatar. * sends the current + new password, asks before signing
* the other devices out, and uploads/removes the avatar or sets it from a link.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -17,6 +18,7 @@ const me = vi.hoisted(() => ({
changePassword: vi.fn(), changePassword: vi.fn(),
signOutOthers: vi.fn(), signOutOthers: vi.fn(),
setAvatar: vi.fn(), setAvatar: vi.fn(),
setAvatarFromUrl: vi.fn(),
removeAvatar: vi.fn(), removeAvatar: vi.fn(),
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null), avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
})); }));
@@ -76,6 +78,55 @@ describe("AccountSettings", () => {
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy(); expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
}); });
/** jsdom never loads an image: play the browser's answer to `src`. */
const stubImage = (loads: boolean) =>
vi.stubGlobal(
"Image",
class {
naturalWidth = loads ? 64 : 0;
referrerPolicy = "";
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_v: string) {
setTimeout(() => (loads ? this.onload?.() : this.onerror?.()), 0);
}
}
);
it("refuses a link this browser cannot load as an image, and saves nothing", async () => {
const ue = userEvent.setup();
stubImage(false);
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
await ue.type(
screen.getByTestId("account-avatar-url-input"),
"https://chat.example/api/v4/users/x/image{Enter}"
);
await waitFor(() => expect(screen.getByTestId("account-avatar-url-input")).toBeTruthy());
await new Promise((r) => setTimeout(r, 20));
expect(me.setAvatarFromUrl).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("sets the avatar from a link and closes the field", async () => {
const ue = userEvent.setup();
stubImage(true);
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
await ue.type(screen.getByTestId("account-avatar-url-input"), " https://x.vn/me.png {Enter}");
await waitFor(() => expect(me.setAvatarFromUrl).toHaveBeenCalledWith("https://x.vn/me.png"));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-21T00:00:00Z"
);
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
vi.unstubAllGlobals();
});
it("shows the account's own information", async () => { it("shows the account's own information", async () => {
render(<AccountSettings />); render(<AccountSettings />);
const card = await screen.findByTestId("account-card"); const card = await screen.findByTestId("account-card");
@@ -114,9 +165,21 @@ describe("AccountSettings", () => {
await ue.type(screen.getByLabelText("Current password"), "old-pass-1"); 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("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123"); await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
// No upper-case letter: the checklist says so and the button stays off.
expect(
screen
.getByTestId("password-rules")
.querySelector('[data-rule="upper"]')
?.getAttribute("data-ok")
).toBe("false");
expect(screen.getByRole("button", { name: "Change password" })).toBeDisabled();
await ue.clear(screen.getByLabelText("New password"));
await ue.clear(screen.getByLabelText("Confirm new password"));
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 ue.click(screen.getByRole("button", { name: "Change password" }));
await waitFor(() => await waitFor(() =>
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123") expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "New-pass-123")
); );
}); });
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
tools: [ tools: [
tool("claude", "Claude Code"), tool("claude", "Claude Code"),
tool("gemini", "Gemini"), tool("gemini", "Gemini"),
tool("grok", "Grok", { enabled: false }), tool("goose", "Goose", { enabled: false }),
tool("aider", "Aider", { installed: false, path: null }), tool("aider", "Aider", { installed: false, path: null }),
], ],
}; };
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
await user.click(within(gemini).getByText("Set default")); await user.click(within(gemini).getByText("Set default"));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true }); expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
// A disabled tool cannot become the default. // A disabled tool cannot become the default.
const grok = screen.getByTestId("ai-tool-grok"); const goose = screen.getByTestId("ai-tool-goose");
expect(within(grok).getByText("Set default")).toBeDisabled(); expect(within(goose).getByText("Set default")).toBeDisabled();
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false"); expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
}); });
it("refreshes detection", async () => { it("refreshes detection", async () => {
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
await user.click(screen.getByRole("menuitem", { name: "Disable" })); await user.click(screen.getByRole("menuitem", { name: "Disable" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false }); expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
// A disabled tool cannot become the default from the menu either. // A disabled tool cannot become the default from the menu either.
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") }); await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled(); expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
}); });
}); });
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => { describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => { it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon"); const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render( const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
);
fireEvent.error(container.querySelector("img")!); fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("O")).toBeInTheDocument(); expect(screen.getByText("A")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />); rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy(); expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
}); });
@@ -0,0 +1,33 @@
/**
* @file ChangesPanelSettings.test.tsx
* @description Settings → Git → Changes panel: the inner-repositories switch
* starts off, turning it on stores `all` in the synced account preference,
* and turning it off again removes the key (back to the default).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ChangesPanelSettings } from "../ChangesPanelSettings";
import { INNER_REPOS_KEY, readInnerReposMode } from "../../../lib/innerReposPref";
describe("ChangesPanelSettings", () => {
beforeEach(() => localStorage.clear());
it("is off by default and toggles the account preference", () => {
render(<ChangesPanelSettings />);
const sw = screen.getByRole("switch", {
name: /Show the repositories inside the workspace folder/,
});
expect(sw).toHaveAttribute("aria-checked", "false");
expect(readInnerReposMode()).toBe("own");
fireEvent.click(sw);
expect(localStorage.getItem(INNER_REPOS_KEY)).toBe("all");
expect(sw).toHaveAttribute("aria-checked", "true");
fireEvent.click(sw);
expect(localStorage.getItem(INNER_REPOS_KEY)).toBeNull();
expect(readInnerReposMode()).toBe("own");
});
});
@@ -5,9 +5,9 @@
* view leaves HTTPS to the shared Git credentials table, and an SSH user per * view leaves HTTPS to the shared Git credentials table, and an SSH user per
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any * host is saved — AWS CodeCommit's key ID inside its own RSA section, any
* other host under "Other SSH hosts" — each kind of host in its own tab, so * other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; a named key * the sections never mix; "Add another key" makes a named key as one more
* is made beside the default ones, and a second AWS account is saved under * card in its own tab, a second AWS account is saved from that card under
* its own name with the one key it uses. * the key's name with that key, and a second GitHub key is tied to its host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
await userEvent.click(within(aws).getByRole("button", { name: "Save" })); await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" }); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument(); expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts". // A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull(); expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` })); await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
@@ -216,9 +215,9 @@ describe("GitIdentitySettings", () => {
fingerprint: "SHA256:r", fingerprint: "SHA256:r",
}; };
const PROD = { const PROD = {
id: "id_rsa_aws-prod", id: "id_rsa_aws-2",
type: "rsa" as const, type: "rsa" as const,
name: "aws-prod", name: "aws-2",
publicKey: "ssh-rsa B prod", publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p", fingerprint: "SHA256:p",
}; };
@@ -230,45 +229,77 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue( vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ view({
sshKeys: [RSA, PROD], sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }], sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
}) })
); );
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue( vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] }) view({ sshKeys: [RSA], sshUsers: [] })
); );
render(<GitIdentitySettings />); render(<GitIdentitySettings />);
// Only an RSA key: the AWS tab opens first. // Only an RSA key: the AWS tab opens first; the new key is made inside it.
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument(); expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
await tab("More keys"); expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
let more = screen.getByRole("region", { name: "More SSH keys" }); // One click makes the key — nothing to type before it.
await userEvent.click(within(more).getByRole("button", { name: "New key" })); await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod"); expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
await userEvent.click(within(more).getByRole("button", { name: "Generate key" })); const prod = await screen.findByRole("region", { name: "aws-2" });
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod"); expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod"); // Another key can be added below it.
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
await tab("AWS CodeCommit"); // Its own step 3: the account name defaults to the key's name.
const aws = screen.getByRole("region", { name: "AWS CodeCommit" }); await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2"); await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H, host: H,
user: "APKA2", user: "APKA2",
alias: "aws-prod", alias: "aws-2",
key: "id_rsa_aws-prod", key: "id_rsa_aws-2",
}); });
expect( expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod") await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument(); // The default key's card does not list the named key's login.
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(def).queryByText(/APKA2/)).toBeNull();
await tab("More keys (1)"); await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
more = screen.getByRole("region", { name: "More SSH keys" }); await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" })); });
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod") it("adds a second GitHub key in its tab and ties it to its host", async () => {
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
const WORK = {
id: "id_ed25519_git-2",
type: "ed25519" as const,
name: "git-2",
publicKey: "ssh-ed25519 W work",
fingerprint: "SHA256:w",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [ED, WORK], sshUsers: [] })
); );
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [ED, WORK],
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
})
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
const work = await screen.findByRole("region", { name: "git-2" });
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "github.com",
user: "git",
alias: "git-2",
key: "id_ed25519_git-2",
});
expect(
await within(work).findByText("ssh://git-2/ → git@github.com")
).toBeInTheDocument();
}); });
}); });
@@ -0,0 +1,95 @@
/**
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
* 9router, a dialog that shows versions and starts the job, the server's
* blocker (with the manual command) disabling the start, and the job's steps.
* @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 { NineRouterUpdate } from "../NineRouterUpdate";
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
const base: NineRouterUpdateStatus = {
installed: true,
version: "0.5.104",
latest: "0.5.109",
updateAvailable: true,
running: true,
managedBy: "systemd",
blocker: null,
job: null,
};
const job: NineRouterUpdateJob = {
status: "running",
startedAt: "2026-10-26T00:00:00Z",
finishedAt: null,
from: "0.5.104",
to: null,
managedBy: "systemd",
install: "direct",
steps: [
{ id: "shutdown", status: "done", message: null },
{ id: "update", status: "running", message: null },
{ id: "relaunch", status: "pending", message: null },
],
log: "",
};
beforeEach(() => {
statusMock.mockReset();
startMock.mockReset();
});
describe("NineRouterUpdate", () => {
it("renders nothing when 9router is not installed", async () => {
statusMock.mockResolvedValue({ ...base, installed: false });
const { container } = render(<NineRouterUpdate />);
await waitFor(() => expect(statusMock).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("opens with both versions and starts the three-step job", async () => {
statusMock.mockResolvedValue(base);
startMock.mockResolvedValue(job);
render(<NineRouterUpdate />);
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
expect(statusMock).toHaveBeenLastCalledWith(true);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("0.5.104");
expect(dialog).toHaveTextContent("0.5.109");
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
expect(startMock).toHaveBeenCalledTimes(1);
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
});
it("shows the blocker with its manual command and keeps the start disabled", async () => {
statusMock.mockResolvedValue({
...base,
blocker: {
code: "ENOPERM",
message: "/usr/local is not writable by this account and sudo needs a password",
command: "sudo npm install -g 9router@latest",
},
});
render(<NineRouterUpdate />);
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
});
});
@@ -0,0 +1,74 @@
/**
* @file Tests for OmpSharingSettings — the admin's Oh My Pi login-sharing
* switch in Settings → AI tools: hidden in local mode, for a non-admin and
* outside S3, and a toggle saves through PUT /api/ai-tools/omp-sharing and
* shows the daemons' state.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "../../../i18n";
import { OmpSharingSettings } from "../OmpSharingSettings";
import { api, type OmpSharingState } from "../../../lib/api";
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", () => ({
api: { aiTools: { ompSharing: vi.fn(), setOmpSharing: vi.fn() } },
}));
const get = vi.mocked(api.aiTools.ompSharing);
const set = vi.mocked(api.aiTools.setOmpSharing);
const off: OmpSharingState = {
available: true,
enabled: false,
installed: true,
broker: { running: false, error: null },
gateway: { running: false, error: null },
};
describe("OmpSharingSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
perms.isAdmin = true;
perms.local = false;
});
it("lets an admin turn sharing on and shows it running", async () => {
get.mockResolvedValue(off);
set.mockResolvedValue({
...off,
enabled: true,
broker: { running: true },
gateway: { running: true, port: 4827 },
});
render(<OmpSharingSettings />);
const sw = await screen.findByRole("switch", { name: "Share this server's Oh My Pi login" });
fireEvent.click(sw);
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("Running");
});
it("names a daemon's failure", async () => {
get.mockResolvedValue({
...off,
enabled: true,
gateway: { running: false, error: "port 4827 is taken" },
});
render(<OmpSharingSettings />);
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("port 4827 is taken");
});
it("renders nothing outside S3, for a non-admin or in local mode", async () => {
get.mockResolvedValue({ ...off, available: false });
const r = render(<OmpSharingSettings />);
await waitFor(() => expect(get).toHaveBeenCalled());
expect(r.container).toBeEmptyDOMElement();
r.unmount();
perms.isAdmin = false;
const r2 = render(<OmpSharingSettings />);
expect(r2.container).toBeEmptyDOMElement();
expect(get).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,72 @@
/**
* @file SessionDefaultsSettings.test.tsx
* @description Session defaults: Bypass becomes Claude's default permission
* mode or Codex's default bypass only after the confirm, a cancelled confirm
* keeps the old default, the plain modes save at once, and the pet chat's
* safe read never returns bypass.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
import { confirmDialog } from "../../../lib/confirm";
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
const aiTools = vi.hoisted(() => ({
tools: [
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
{ id: "codex", label: "Codex", installed: true, enabled: true },
],
}));
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
const confirm = vi.mocked(confirmDialog);
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
describe("SessionDefaultsSettings", () => {
beforeEach(() => {
localStorage.clear();
confirm.mockReset();
});
it("saves a plain mode without asking", () => {
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/plan/i));
expect(confirm).not.toHaveBeenCalled();
expect(readDefaultMode()).toBe("plan");
});
it("makes Bypass Claude's default only once confirmed", async () => {
confirm.mockResolvedValueOnce(false);
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
expect(readDefaultMode()).toBe("auto");
confirm.mockResolvedValueOnce(true);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
expect(readDefaultMode({ safe: true })).toBe("auto");
});
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
confirm.mockResolvedValueOnce(true);
render(<SessionDefaultsSettings />);
const sw = screen.getByRole("switch");
fireEvent.click(sw);
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
fireEvent.click(screen.getByRole("switch"));
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
expect(confirm).toHaveBeenCalledTimes(1);
});
it("shows nothing when neither Claude nor Codex is enabled", () => {
const saved = aiTools.tools;
aiTools.tools = [];
const { container } = render(<SessionDefaultsSettings />);
expect(container.firstChild).toBeNull();
aiTools.tools = saved;
});
});
@@ -0,0 +1,178 @@
/**
* @file Tests for ToolProfilesSettings — Settings → AI tools → Profiles: one
* tab per AI tool. Claude's tab asks for EVERY saved account's quota once (a
* failing account costs the others nothing), and its right-click delete asks
* first and never offers to delete the only copy of the live account; Codex's
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
* switched; and in server mode a profile links into the account pool.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
import { settleConfirm } from "../../../lib/confirm";
import { ToolProfilesSettings } from "../ToolProfilesSettings";
const perms = vi.hoisted(() => ({ isAdmin: true, local: true, seesAll: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../hooks/useAiTools", () => ({
useAiTools: () => ({
byId: (id: string) => ({ id, installed: true, enabled: true }),
}),
}));
const m = vi.hoisted(() => {
const window = (utilization: number) => ({ utilization, resetsAt: "2026-09-18T08:00:00Z" });
return {
window,
cc: {
profiles: vi.fn(),
profileUsage: vi.fn(),
deleteProfile: vi.fn(),
activateProfile: vi.fn(),
saveProfile: vi.fn(),
renameProfile: vi.fn(),
},
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
pool: { list: vi.fn() },
};
});
vi.mock("../../../lib/api", () => ({
api: {
ccConfig: m.cc,
toolProfiles: m.tp,
accountPool: m.pool,
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
},
}));
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function show() {
render(
<MemoryRouter>
<ToolProfilesSettings />
</MemoryRouter>
);
await settle();
await settle();
}
beforeEach(() => {
vi.clearAllMocks();
perms.local = true;
m.cc.profiles.mockResolvedValue({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
],
active: { accountUuid: "u1", email: "a@b.c" },
});
m.cc.profileUsage.mockImplementation(async (name: string) => {
if (name === "spare") throw new Error("this profile's saved login has been superseded");
return { name, refreshed: false, fiveHour: m.window(8), sevenDay: m.window(70) };
});
m.cc.deleteProfile.mockResolvedValue({ ok: true, name: "spare" });
m.tp.list.mockResolvedValue({
tools: [
{
tool: "codex",
supported: true,
items: [
{
name: "a",
label: "Alice",
active: true,
kind: "chatgpt",
email: "a@x",
detail: "plus",
},
{ name: "b", label: "Bob", active: false, kind: "apikey", email: null, detail: "…1234" },
],
active: { kind: "chatgpt", email: "a@x", detail: "plus" },
},
{ tool: "omp", supported: true, items: [], active: null },
{ tool: "antigravity", supported: false, reason: "keyring", items: [], active: null },
],
});
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
});
describe("ToolProfilesSettings — Claude", () => {
it("asks for every saved account's quota once, a failure costing the others nothing", async () => {
await show();
await settle();
expect(m.cc.profileUsage).toHaveBeenCalledWith("work");
expect(m.cc.profileUsage).toHaveBeenCalledWith("spare");
expect(m.cc.profileUsage.mock.calls.filter((c) => c[0] === "work")).toHaveLength(1);
expect(await screen.findByText("8%")).toBeInTheDocument();
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
});
it("deletes from the right-click menu only after confirming", async () => {
const user = userEvent.setup();
await show();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete"));
await settle();
expect(m.cc.deleteProfile).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(m.cc.deleteProfile).toHaveBeenCalledWith("spare");
});
it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup();
await show();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
});
});
describe("ToolProfilesSettings — other tools", () => {
it("switches a Codex login through /api/tool-profiles", async () => {
const user = userEvent.setup();
await show();
await user.click(screen.getByRole("tab", { name: /codex/i }));
const bob = screen.getByTestId("profile-row-codex-b");
expect(bob).toHaveTextContent("…1234");
await user.click(screen.getByRole("button", { name: "Switch to" }));
await settle();
expect(m.tp.activate).toHaveBeenCalledWith("codex", "b");
expect(m.tp.list).toHaveBeenCalledTimes(2);
});
it("says why Antigravity cannot be switched", async () => {
const user = userEvent.setup();
await show();
await user.click(screen.getByRole("tab", { name: /antigravity/i }));
expect(screen.getByText(/system keyring/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
});
});
describe("ToolProfilesSettings — account pool", () => {
it("is not shown in local mode", async () => {
await show();
expect(m.pool.list).not.toHaveBeenCalled();
expect(screen.queryByText(/Move to pool/)).toBeNull();
});
it("in server mode shows the pool's size and links a profile into it", async () => {
perms.local = false;
await show();
expect(screen.getByText(/holds 1 account/)).toBeInTheDocument();
const [link] = screen.getAllByRole("link", { name: /Move to pool/ });
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
});
});
+640 -167
View File
@@ -1,38 +1,56 @@
/** /**
* @file AccountPoolCard.tsx * @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`): the * @description The Users page's account pool card (`GET /api/accounts`), one
* Claude and Codex accounts every user's new sessions are spread over, each * tab per AI tool so each tool's pool is managed on its own: Claude Code,
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in * Codex and Cursor have a pool — the accounts every user's new sessions of that
* per user. An admin adds one — for Claude a login MOVED in from a saved * tool are spread over, each with its live panes (and, for Claude/Codex, $ and
* switch-user profile (`GET /api/accounts/profiles`), a pasted * tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI * Oh My Pi has no pool but can share the host's login (the same switch as
* API key or a pasted `auth.json` — or removes one, each time with their own * Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
* password again; an analyst only reads. The secret only goes IN: the server * other installed tool says that its users sign in themselves. A tab shows
* never sends it back. An account's Edit (pencil) picks the users who run on * how many accounts its pool holds, and an empty pool says so instead of
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and * vanishing. An admin adds an account to the open tab's tool — for Claude a
* a Claude login goes into their home at once — no restart. * login MOVED in from a saved profile (Settings → AI tools → Profiles,
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
* `claude setup-token` token; for Codex a saved Codex profile
* (`?tool=codex`), an OpenAI API key or a pasted `auth.json`; for Cursor a
* Cursor API key — or removes one, each time with their own password again;
* an analyst only reads. The secret only goes IN: the server never sends it
* back. An account's Edit (pencil) picks the users who run on it
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
* Claude login goes into their home at once — no restart. A profile's
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
* which opens that tool's tab and Add on that profile; the header links back
* to the profiles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Link, useSearchParams } from "react-router-dom";
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
import { import {
api, api,
type AccountPin, type AccountPin,
type AccountPool, type AccountPool,
type OmpSharingState,
type PoolAccountInput, type PoolAccountInput,
type PoolProfile, type PoolProfile,
type PoolTool, type PoolTool,
type ServerUserUsage, type ServerUserUsage,
type CcUsageWindow,
} from "../../lib/api"; } from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format"; import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin"; import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsSwitchRow } from "../settings/SettingsRow";
import { PoolGroups, groupsOf } from "./PoolGroups";
type Row = { type Row = {
tool: PoolTool; tool: PoolTool;
@@ -49,19 +67,81 @@ type Row = {
tokens7d: number; tokens7d: number;
quotaPercent: number | null; quotaPercent: number | null;
full: boolean; full: boolean;
/** The 5-hour and weekly windows, as the account last reported them. */
windows: QuotaWindow[];
}; };
function rowsOf(pool: AccountPool): Row[] { /** One quota window of an account: % used and when it rolls over. */
return [ type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
...pool.codex.map((a) => ({ /**
...a, * The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
tool: "codex" as const, * `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
expired: false, * passed is empty again (the reading predates it), as the balancer treats it.
email: null, */
refreshError: null, function windowsOf(
})), a: AccountPool["accounts"][number] | AccountPool["codex"][number]
]; ): QuotaWindow[] {
const q = a.quota as
| {
fiveHour?: CcUsageWindow | null;
sevenDay?: CcUsageWindow | null;
windows?: { label: string; usedPercent: number; resetsAt: string | null }[];
}
| null
| undefined;
const fromClaude = (w: CcUsageWindow | null | undefined) =>
w ? { used: w.utilization, resetsAt: w.resetsAt } : null;
const fromCodex = (label: string) => {
const w = q?.windows?.find((x) => x.label === label);
return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null;
};
return (["5h", "wk"] as const).map((key) => {
const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key);
const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now();
return {
key,
used: passed ? 0 : (w?.used ?? null),
resetsAt: passed ? null : (w?.resetsAt ?? null),
};
});
}
/** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */
function untilText(iso: string | null): string | null {
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
if (!(ms > 0)) return null;
const m = Math.floor(ms / 60_000);
const d = Math.floor(m / 1440);
const h = Math.floor((m % 1440) / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
}
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
/** Fixed tabs, always shown; any other installed tool follows them. */
const FIXED_TABS: { id: string; label: string }[] = [
{ id: "claude", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "cursor", label: "Cursor CLI" },
{ id: "omp", label: "Oh My Pi" },
];
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
const METERED = new Set<PoolTool>(["claude", "codex"]);
const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id);
function rowsOf(pool: AccountPool, tool: PoolTool): Row[] {
if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) }));
const list = tool === "codex" ? pool.codex : (pool.cursor ?? []);
return list.map((a) => ({
...a,
tool,
expired: false,
email: null,
refreshError: null,
windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [],
}));
} }
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? []; const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
@@ -75,9 +155,30 @@ function errorText(t: TFunction<"users">, e: unknown): string {
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const aiTools = useAiTools();
const [pool, setPool] = useState<AccountPool | null>(null); const [pool, setPool] = useState<AccountPool | null>(null);
const [omp, setOmp] = useState<OmpSharingState | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false); const [params, setParams] = useSearchParams();
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
const preset = (() => {
const tool = params.get("poolAdd");
const profile = params.get("poolProfile");
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
})();
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
// What Add opens on: a tool, and a saved profile to share into its pool.
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
isAdmin && preset ? preset : null
);
const closeAdd = () => {
setAdding(null);
if (!preset) return;
const next = new URLSearchParams(params);
next.delete("poolAdd");
next.delete("poolProfile");
setParams(next, { replace: true });
};
const [removing, setRemoving] = useState<Row | null>(null); const [removing, setRemoving] = useState<Row | null>(null);
const [editing, setEditing] = useState<Row | null>(null); const [editing, setEditing] = useState<Row | null>(null);
@@ -96,19 +197,38 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
useEffect(() => { useEffect(() => {
void load(); void load();
// Oh My Pi's tab: the host's login shared through omp's own gateway (S3).
api.aiTools
.ompSharing()
.then(setOmp)
.catch(() => setOmp(null));
}, [load]); }, [load]);
const rows = pool ? rowsOf(pool) : []; // Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied.
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"; const tabs = [
...FIXED_TABS,
...aiTools.tools
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
.map((x) => ({ id: x.id, label: x.label })),
];
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
const badge = (id: string): string | null => {
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
return null;
};
return ( return (
<section className="card" data-testid="account-pool"> <section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border"> <div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<KeyRound className="w-4 h-4 text-fg-muted" /> <KeyRound className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-[14rem]">
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2> <h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
<p className="text-xs text-fg-muted">{t("pool.description")}</p> <p className="text-xs text-fg-muted">{t("pool.description")}</p>
</div> </div>
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
{t("pool.profilesLink")}
</Link>
<button <button
type="button" type="button"
onClick={refresh} onClick={refresh}
@@ -120,125 +240,78 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
> >
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} /> <RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button> </button>
{isAdmin && ( </div>
<button type="button" onClick={() => setAdding(true)} className="btn-primary"> {pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
<Plus className="w-4 h-4" /> {t("pool.add")} <div
</button> role="tablist"
)} aria-label={t("pool.title")}
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
>
{tabs.map((x) => {
const selected = x.id === current.id;
const count = badge(x.id);
return (
<button
key={x.id}
type="button"
role="tab"
id={`pool-tab-${x.id}`}
aria-selected={selected}
aria-controls="pool-panel"
onClick={() => setTab(x.id)}
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
selected
? "border-accent text-fg-primary font-medium"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<AiToolIcon tool={x} className="h-4 w-4" />
{x.label}
{count !== null && (
<span
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
}`}
>
{count}
</span>
)}
</button>
);
})}
</div> </div>
{error && ( {error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger"> <p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error} {error}
</p> </p>
)} )}
{pool && rows.length === 0 && ( <div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p> {isPoolTool(current.id) ? (
)} <PoolPanel
{rows.length > 0 && ( tool={current.id}
<div className="overflow-x-auto"> label={current.label}
<table className="w-full min-w-[640px]"> pool={pool}
<thead> isAdmin={isAdmin}
<tr className="border-b border-border text-left"> installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
<th className={th}>{t("pool.account")}</th> onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
<th className={th}>{t("pool.tool")}</th> onEdit={setEditing}
<th className={`${th} text-right`}>{t("pool.livePanes")}</th> onRemove={setRemoving}
<th className={`${th} text-right`}>{t("pool.quota")}</th> />
<th className={`${th} text-right`}>{t("pool.spend")}</th> ) : current.id === "omp" ? (
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>} <OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
</tr> ) : (
</thead> <NoPoolPanel id={current.id} label={current.label} />
<tbody className="divide-y divide-border"> )}
{rows.map((r) => ( </div>
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
<td className="px-5 py-2.5">
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
<div className="text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
<div className="text-[11px] text-fg-secondary">
{t("pool.pinned", {
users: pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username)
.join(", "),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{t(`pool.kind.${r.kind}`)}
{r.expired && (
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("pool.expired")}
</span>
)}
{r.refreshError && !r.expired && (
<span
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={r.refreshError}
>
{t("pool.refreshFailed")}
</span>
)}
{r.full && (
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
{t("pool.full")}
</span>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{r.livePanes}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
</td>
<td className="px-5 py-2.5 text-right tabular-nums">
<div className="text-sm text-fg-primary">
{fmtCost(r.spend5h)}
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
</div>
<div className="text-[11px] text-fg-muted">
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</div>
</td>
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => setEditing(r)}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setRemoving(r)}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{adding && ( {adding && (
<AddAccountDialog <AddAccountDialog
onClose={() => setAdding(false)} tool={adding.tool}
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
onClose={closeAdd}
onDone={(name) => { onDone={(name) => {
toast.success(t("pool.done.added", { name })); toast.success(t("pool.done.added", { name }));
setAdding(false); closeAdd();
void load(); void load();
}} }}
/> />
@@ -278,6 +351,398 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
); );
} }
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
function PoolPanel({
tool,
label,
pool,
isAdmin,
installed,
onAdd,
onEdit,
onRemove,
}: {
tool: PoolTool;
label: string;
pool: AccountPool | null;
isAdmin: boolean;
installed: boolean;
/** Add an account; `profile` = share that saved profile into the pool. */
onAdd: (profile?: string) => void;
onEdit: (r: Row) => void;
onRemove: (r: Row) => void;
}) {
const { t } = useTranslation("users");
const rows = pool ? rowsOf(pool, tool) : [];
const grouped = (pool?.groups ?? []).length > 0;
const addButton = isAdmin && (
<button type="button" onClick={() => onAdd()} className="btn-primary">
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
</button>
);
return (
<>
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
{t(`pool.about.${tool}`)}
{!installed && (
<span className="ml-1 text-status-warning">
{t("pool.notInstalled", { tool: label })}
</span>
)}
</p>
{rows.length > 0 && addButton}
</div>
{pool && rows.length === 0 && (
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
{addButton}
</div>
)}
{rows.length > 0 && (
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((r) => (
<AccountCard
key={r.name}
row={r}
pool={pool!}
grouped={grouped}
isAdmin={isAdmin}
onEdit={() => onEdit(r)}
onRemove={() => onRemove(r)}
/>
))}
</div>
)}
{isAdmin && SOURCES[tool].includes("profile") && (
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
)}
</>
);
}
/** One pool account: who it is, how loaded, which groups and users run on it. */
function AccountCard({
row: r,
pool,
grouped,
isAdmin,
onEdit,
onRemove,
}: {
row: Row;
pool: AccountPool;
grouped: boolean;
isAdmin: boolean;
onEdit: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation("users");
const metered = METERED.has(r.tool);
const users = pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username);
const inGroups = groupsOf(pool, r.tool, r.name);
return (
<div
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
}`}
data-testid="pool-account"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
</div>
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
{isAdmin && (
<div className="-mr-1 -mt-1 flex shrink-0">
<button
type="button"
onClick={onEdit}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRemove}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
</div>
{(r.expired || r.refreshError || r.full) && (
<div className="flex flex-wrap gap-1">
{r.expired && (
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("pool.expired")}
</span>
)}
{r.refreshError && !r.expired && (
<span
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={r.refreshError}
>
{t("pool.refreshFailed")}
</span>
)}
{r.full && (
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
{t("pool.full")}
</span>
)}
</div>
)}
<div className="flex items-center gap-3 text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
<span
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
aria-hidden
/>
{t("pool.liveCount", { count: r.livePanes })}
</span>
</div>
{metered && (
<div className="space-y-1.5" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
{metered && (
<div className="text-[11px] tabular-nums text-fg-muted">
<div>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</div>
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
</div>
)}
{grouped && (
<div className="flex flex-wrap items-center gap-1 text-[11px]">
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
{inGroups.length === 0 ? (
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
) : (
inGroups.map((g) => (
<span key={g.id} className="badge py-0 text-[10px]">
{g.name}
</span>
))
)}
</div>
)}
{users.length > 0 && (
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<UserRound className="h-3 w-3" aria-hidden />
{t("pool.pinned", { users: users.join(", ") })}
</div>
)}
<div className="mt-auto text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
</div>
);
}
/**
* One window of an account's quota, shown as what is LEFT (100% = untouched,
* 0% = used up), with when it resets.
*/
function QuotaBar({ window: w }: { window: QuotaWindow }) {
const { t } = useTranslation("users");
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
const resets = untilText(w.resetsAt);
return (
<div className="text-[11px]">
<div className="flex items-center gap-1.5">
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full ${
left == null
? ""
: left === 0
? "bg-status-danger"
: left <= 10
? "bg-status-warning"
: "bg-accent-solid"
}`}
style={{ width: `${left ?? 0}%` }}
/>
</span>
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
{left == null ? "—" : `${left}%`}
</span>
</div>
{resets && (
<div
className="pl-[3.875rem] text-[10px] text-fg-muted"
title={formatDateTime(w.resetsAt!)}
>
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
</div>
)}
</div>
);
}
/**
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
* leaves the switcher, since a login's refresh token may have one holder.
*/
function SavedProfiles({
tool,
pool,
onShare,
}: {
tool: PoolTool;
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
pool: AccountPool | null;
onShare: (name: string) => void;
}) {
const { t } = useTranslation("users");
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
useEffect(() => {
api.accountPool
.profiles(tool)
.then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([]));
}, [tool, pool]);
return (
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
<div className="flex flex-wrap items-center gap-2">
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.savedProfiles.title")}
</h3>
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
{t("pool.profilesLink")}
</Link>
</div>
{profiles !== null && profiles.length === 0 && (
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
)}
{profiles && profiles.length > 0 && (
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => (
<div
key={p.name}
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
>
<div className="flex-1 min-w-0">
<div className="truncate text-sm text-fg-primary">{p.label}</div>
<div className="truncate text-[11px] text-fg-muted">
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
</div>
</div>
<button
type="button"
onClick={() => onShare(p.name)}
className="btn-ghost shrink-0 text-xs"
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
>
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
</button>
</div>
))}
</div>
)}
</div>
);
}
/**
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
* same admin switch as Settings → AI tools. Only with per-user accounts (S3).
*/
function OmpPanel({
state,
isAdmin,
onChange,
}: {
state: OmpSharingState | null;
isAdmin: boolean;
onChange: (s: OmpSharingState) => void;
}) {
const { t } = useTranslation("users");
const ts = useTranslation("settings").t;
const [busy, setBusy] = useState(false);
if (!state?.available) {
return (
<div className="flex items-start gap-3 px-5 py-6">
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
</div>
);
}
const error = state.broker.error || state.gateway.error;
const status = !state.enabled
? t("pool.omp.off")
: !state.installed
? ts("ompSharing.notInstalled")
: state.broker.running && state.gateway.running
? ts("ompSharing.running")
: error
? ts("ompSharing.failed", { error })
: ts("ompSharing.starting");
return (
<div className="px-5 py-3 space-y-2">
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
{isAdmin ? (
<SettingsSwitchRow
label={ts("ompSharing.switch")}
description={ts("ompSharing.hint")}
checked={state.enabled}
disabled={busy}
onChange={async (on) => {
setBusy(true);
try {
onChange(await api.aiTools.setOmpSharing(on));
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
/>
) : null}
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
{status}
</p>
</div>
);
}
/** Any other tool: no pool — say so, and why for the ones we know. */
function NoPoolPanel({ id, label }: { id: string; label: string }) {
const { t } = useTranslation("users");
return (
<div className="flex items-start gap-3 px-5 py-6">
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
<div className="space-y-1">
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
<p className="text-xs text-fg-muted leading-relaxed">
{t(`pool.none.reason.${id}`, {
defaultValue: t("pool.none.reason.default", { tool: label }),
})}
</p>
</div>
</div>
);
}
/** /**
* One account's Edit: tick the users whose sessions run on it. A user already * One account's Edit: tick the users whose sessions run on it. A user already
* on another account of the tool moves here when ticked; unticking one sends * on another account of the tool moves here when ticked; unticking one sends
@@ -412,19 +877,33 @@ function EditAccountDialog({
} }
type ClaudeSource = "profile" | "credentials" | "token"; type ClaudeSource = "profile" | "credentials" | "token";
/** What each tool's pool account can come from (server/routes/accounts.js). */
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
claude: ["profile", "credentials", "token"],
codex: ["profile", "token"],
cursor: ["token"],
};
function AddAccountDialog({ function AddAccountDialog({
tool,
label,
preset,
onClose, onClose,
onDone, onDone,
}: { }: {
/** The open tab's tool: the account is added to its pool. */
tool: PoolTool;
label: string;
/** A saved profile to move in, from its "Move to pool" link. */
preset?: { tool: PoolTool; profile: string } | null;
onClose: () => void; onClose: () => void;
onDone: (name: string) => void; onDone: (name: string) => void;
}) { }) {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const [tool, setTool] = useState<PoolTool>("claude"); const sources = SOURCES[tool];
const [source, setSource] = useState<ClaudeSource>("profile"); const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null); const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
const [profile, setProfile] = useState(""); const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [secret, setSecret] = useState(""); const [secret, setSecret] = useState("");
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
@@ -432,13 +911,14 @@ function AddAccountDialog({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!sources.includes("profile")) return;
api.accountPool api.accountPool
.profiles() .profiles(tool)
.then((r) => setProfiles(r.profiles)) .then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([])); .catch(() => setProfiles([]));
}, []); }, [tool, sources]);
const fromProfile = tool === "claude" && source === "profile"; const fromProfile = source === "profile";
// A profile already names itself: its name is the default. // A profile already names itself: its name is the default.
const ready = const ready =
current.length > 0 && current.length > 0 &&
@@ -449,13 +929,11 @@ function AddAccountDialog({
setBusy(true); setBusy(true);
setError(null); setError(null);
const body: PoolAccountInput = const body: PoolAccountInput =
tool === "codex" source === "profile"
? { tool, name: name.trim(), token: secret.trim() } ? { tool, name: name.trim() || profile, profile }
: source === "profile" : tool === "claude" && source === "credentials"
? { tool, name: name.trim() || profile, profile } ? { tool, name: name.trim(), credentials: secret.trim() }
: source === "credentials" : { tool, name: name.trim(), token: secret.trim() };
? { tool, name: name.trim(), credentials: secret.trim() }
: { tool, name: name.trim(), token: secret.trim() };
try { try {
const r = await api.accountPool.add(body, current); const r = await api.accountPool.add(body, current);
onDone(r.account.name); onDone(r.account.name);
@@ -472,12 +950,13 @@ function AddAccountDialog({
void submit(); void submit();
} }
}; };
const secretKind = tool === "codex" ? "codex" : source; // Codex's and Cursor's one non-profile source is their own kind of key.
const secretKind = tool === "claude" ? source : tool;
return ( return (
<ConfirmModal <ConfirmModal
open open
title={t("pool.add")} title={t("pool.addFor", { tool: label })}
confirmLabel={t("pool.add")} confirmLabel={t("pool.add")}
cancelLabel={t("common:cancel")} cancelLabel={t("common:cancel")}
destructive={false} destructive={false}
@@ -487,28 +966,22 @@ function AddAccountDialog({
onCancel={onClose} onCancel={onClose}
> >
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}> <div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
<Field label={t("pool.tool")}> {sources.length > 1 && (
<select <Field
className="input w-full text-sm" label={t("pool.source")}
value={tool} hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
onChange={(e) => setTool(e.target.value as PoolTool)}
aria-label={t("pool.tool")}
> >
<option value="claude">Claude Code</option>
<option value="codex">Codex</option>
</select>
</Field>
{tool === "claude" && (
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
<select <select
className="input w-full text-sm" className="input w-full text-sm"
value={source} value={source}
onChange={(e) => setSource(e.target.value as ClaudeSource)} onChange={(e) => setSource(e.target.value as ClaudeSource)}
aria-label={t("pool.source")} aria-label={t("pool.source")}
> >
{(["profile", "credentials", "token"] as const).map((s) => ( {sources.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{t(`pool.sources.${s}`)} {tool !== "claude" && s === "token"
? t(`pool.secret.${tool}`)
: t(`pool.sources.${s}`)}
</option> </option>
))} ))}
</select> </select>
@@ -553,7 +1026,7 @@ function AddAccountDialog({
</Field> </Field>
{!fromProfile && ( {!fromProfile && (
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}> <Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
{secretKind === "token" ? ( {secretKind === "token" || secretKind === "cursor" ? (
<input <input
type="password" type="password"
className="input w-full font-mono text-sm" className="input w-full font-mono text-sm"
@@ -0,0 +1,39 @@
/**
* @file PasswordRules.tsx
* @description The checklist under a "new password" field the account picks
* for itself (Settings → Account, the first-login password change, an admin's
* own password on the Users page): each rule of `lib/passwordRules.ts`, ticked
* as the password typed passes it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Check, Circle } from "lucide-react";
import { PASSWORD_MIN_LENGTH, PASSWORD_RULES, passwordProblems } from "../../lib/passwordRules";
export function PasswordRules({ password, username }: { password: string; username?: string }) {
const { t } = useTranslation("users");
const failed = new Set(passwordProblems(password, username));
return (
<ul className="grid gap-x-4 gap-y-1 text-xs sm:grid-cols-2" data-testid="password-rules">
{PASSWORD_RULES.map((rule) => {
const ok = password.length > 0 && !failed.has(rule);
return (
<li
key={rule}
className={`flex items-center gap-1.5 ${ok ? "text-status-success" : "text-fg-muted"}`}
data-rule={rule}
data-ok={ok}
>
{ok ? (
<Check className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<Circle className="h-3 w-3 flex-shrink-0" />
)}
{t(`passwordRules.${rule}`, { min: PASSWORD_MIN_LENGTH })}
</li>
);
})}
</ul>
);
}
+435
View File
@@ -0,0 +1,435 @@
/**
* @file PoolGroups.tsx
* @description The account pool's GROUPS (`/api/accounts/groups`,
* server/lib/pool-groups.js), shown as cards above the tool tabs: each team's
* members run only on the accounts its group lists, of every tool, while a
* user in no group runs on the shared pool (the accounts no group lists). A
* card shows the group's members and its accounts per tool; an admin creates,
* edits (name, members — one group per user, so ticking one moves them here —
* and accounts) or deletes one, each time with their own password again.
* `groupsOf` tells an account card which groups use it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Pencil, Plus, Trash2, Users } from "lucide-react";
import {
api,
type AccountPool,
type PoolGroup,
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
import { AiToolIcon } from "../AiToolIcon";
/** Pool tools with their tab labels, in tab order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
{ id: "claude", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "cursor", label: "Cursor CLI" },
];
/** Account names of `tool` in the pool. */
function accountNames(pool: AccountPool, tool: PoolTool): string[] {
const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor;
return (list ?? []).map((a) => a.name);
}
/** The groups that list account `name` of `tool`. */
export function groupsOf(pool: AccountPool, tool: PoolTool, name: string): PoolGroup[] {
return (pool.groups ?? []).filter((g) => (g.accounts[tool] ?? []).includes(name));
}
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
? t(`pool.errors.${err.code}`)
: err.message;
}
export function PoolGroups({
pool,
isAdmin,
onChanged,
}: {
pool: AccountPool;
isAdmin: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation("users");
const groups = pool.groups ?? [];
const [editing, setEditing] = useState<PoolGroup | "new" | null>(null);
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
return (
<div className="px-5 py-4 border-b border-border" data-testid="pool-groups">
<div className="flex flex-wrap items-center gap-2">
<Users className="h-4 w-4 text-fg-muted" aria-hidden />
<div className="flex-1 min-w-[14rem]">
<h3 className="text-sm font-semibold text-fg-primary">{t("pool.groups.title")}</h3>
<p className="text-xs text-fg-muted">{t("pool.groups.description")}</p>
</div>
{isAdmin && (
<button type="button" onClick={() => setEditing("new")} className="btn-ghost text-xs">
<Plus className="h-4 w-4" /> {t("pool.groups.new")}
</button>
)}
</div>
{groups.length === 0 ? (
<p className="mt-3 text-xs text-fg-muted">{t("pool.groups.empty")}</p>
) : (
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => (
<div
key={g.id}
className="rounded-lg border border-border bg-surface-2 p-3"
data-testid="pool-group"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{g.name}</div>
<div className="text-[11px] text-fg-muted">
{t("pool.groups.members", { count: g.users.length })}
</div>
</div>
{isAdmin && (
<>
<button
type="button"
onClick={() => setEditing(g)}
title={t("pool.groups.edit", { name: g.name })}
aria-label={t("pool.groups.edit", { name: g.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setDeleting(g)}
title={t("pool.groups.delete", { name: g.name })}
aria-label={t("pool.groups.delete", { name: g.name })}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
{g.users.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{g.users.map((u) => (
<span key={u.userId} className="badge py-0 text-[11px]">
{u.username}
</span>
))}
</div>
)}
<div className="mt-2 space-y-1">
{POOL_TOOL_LABELS.map(({ id, label }) => {
const names = g.accounts[id] ?? [];
return (
<div key={id} className="flex items-center gap-1.5 text-[11px]">
<AiToolIcon tool={{ id, label }} className="h-3.5 w-3.5" />
<span className="text-fg-secondary">{label}:</span>
<span className={names.length ? "text-fg-primary" : "text-fg-muted"}>
{names.length ? names.join(", ") : t("pool.groups.noAccounts")}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{editing && (
<GroupDialog
pool={pool}
group={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
onDone={(name) => {
toast.success(t("pool.groups.saved", { name }));
setEditing(null);
onChanged();
}}
/>
)}
{deleting && (
<DeleteGroupDialog
group={deleting}
onClose={() => setDeleting(null)}
onDone={() => {
toast.success(t("pool.groups.deleted", { name: deleting.name }));
setDeleting(null);
onChanged();
}}
/>
)}
</div>
);
}
/** Create a group, or edit one: its name, its members and its accounts. */
function GroupDialog({
pool,
group,
onClose,
onDone,
}: {
pool: AccountPool;
group: PoolGroup | null;
onClose: () => void;
onDone: (name: string) => void;
}) {
const { t } = useTranslation("users");
const [name, setName] = useState(group?.name ?? "");
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [members, setMembers] = useState<Set<number>>(
() => new Set(group?.users.map((u) => u.userId) ?? [])
);
const [accounts, setAccounts] = useState<Record<PoolTool, Set<string>>>(() => ({
claude: new Set(group?.accounts.claude ?? []),
codex: new Set(group?.accounts.codex ?? []),
cursor: new Set(group?.accounts.cursor ?? []),
}));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const groupOfUser = (userId: number) =>
(pool.groups ?? []).find((g) => g.id !== group?.id && g.users.some((u) => u.userId === userId));
const toggle = <T,>(set: Set<T>, v: T, on: boolean) => {
const next = new Set(set);
if (on) next.add(v);
else next.delete(v);
return next;
};
const ready = name.trim().length > 0 && current.length > 0;
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
const id = group
? group.id
: (await api.accountPool.createGroup(name.trim(), current)).group.id;
await api.accountPool.updateGroup(
id,
{
...(group ? { name: name.trim() } : {}),
userIds: [...members],
accounts: Object.fromEntries(
POOL_TOOL_LABELS.map(({ id: tool }) => [tool, [...accounts[tool]]])
),
},
current
);
onDone(name.trim());
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={group ? t("pool.groups.edit", { name: group.name }) : t("pool.groups.new")}
message={t("pool.groups.hint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("pool.groups.name")}</span>
<input
className="input mt-1 w-full text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
aria-label={t("pool.groups.name")}
/>
</label>
<div>
<span className="text-xs font-medium text-fg-secondary">
{t("pool.groups.memberList")}
</span>
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const other = groupOfUser(u.id);
return (
<div key={u.id} className="px-3 py-1.5">
<Checkbox
checked={members.has(u.id)}
onChange={(on) => setMembers((s) => toggle(s, u.id, on))}
label={
<span>
{u.username}
{other && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.groups.inGroup", { group: other.name })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
</div>
</div>
<div>
<span className="text-xs font-medium text-fg-secondary">
{t("pool.groups.accountList")}
</span>
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
{POOL_TOOL_LABELS.map(({ id: tool, label }) => {
const names = accountNames(pool, tool);
return (
<div key={tool} className="px-3 py-2">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-fg-secondary">
<AiToolIcon tool={{ id: tool, label }} className="h-3.5 w-3.5" />
{label}
</div>
{names.length === 0 ? (
<p className="mt-1 text-[11px] text-fg-muted">{t("pool.groups.noneInPool")}</p>
) : (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{names.map((n) => (
<Checkbox
key={n}
checked={accounts[tool].has(n)}
onChange={(on) =>
setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) }))
}
label={n}
labelClassName="text-sm text-fg-primary"
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function DeleteGroupDialog({
group,
onClose,
onDone,
}: {
group: PoolGroup;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
await api.accountPool.deleteGroup(group.id, current);
onDone();
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.groups.delete", { name: group.name })}
message={t("pool.groups.deleteHint")}
confirmLabel={t("pool.removeButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<label className="mt-3 block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
}

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