Compare commits

...

10 Commits

Author SHA1 Message Date
nntrivi2001 96e4d28f7c fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists.
  The server was forked from the dashboard's cwd (the plugin cache dir),
  outlives updates, and the old cache dir is pruned — after that every new
  pane started in a deleted directory, where `claude` died at once ("run not
  found") and a shell printed getcwd errors. tmux calls now run from `/`,
  and the pane's sh wrapper cds into its folder itself, which also heals a
  tmux server that is already stuck.
- Changes panel: a workspace's extra folders are sections stacked under the
  workspace folder (VS Code Source Control style), with Close repository /
  Close other repositories on a header's right-click.
- Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]`
  line folds into its turn; uploads under `<data dir>/drops/` are listed.
- Update check: a checkout holding commits a newer release lacks reports
  `diverged_from_release` instead of offering a fast-forward that can only fail.
2026-10-09 17:24:14 +07:00
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

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

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

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

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

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

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

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

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

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

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

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

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
2026-10-08 12:22:14 +07:00
345 changed files with 31877 additions and 5493 deletions
+5
View File
@@ -91,6 +91,11 @@
# (default: enabled).
# AGENTHUB_PRICING_SYNC=0
# The status bar's Usage panel reads Antigravity's quota by running
# `agy --print /usage --output-format json` (no model call), which may let agy
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
# AGENTHUB_AGY_USAGE=0
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
# their own connection so they never stall other requests. 0 runs them inline
# on the event loop instead (default: worker). A job past the timeout answers
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
# commit-msg hook – runs Commitlint
npx --no-install commitlint --edit "$1"
+3 -1
View File
@@ -374,7 +374,7 @@ graph TD
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<tmpdir>/agenthub-drops-<uid>/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
@@ -2244,6 +2244,7 @@ interface UpdateStatusPayload {
situation?: // categorical hint for the UI
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
| "release_available" // fast-forward onto the release tag
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
@@ -2309,6 +2310,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch <remote> --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep <tag>` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
### Why Self-Apply Stays Narrow
+10 -6
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -18,7 +18,7 @@ result and `/agenthub-open` to build the UI and get the URL.
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
the server stores data through `node:sqlite`). Full behavior, including the
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
Follow the steps below instead when you want a checkout — to develop the
dashboard, run it from source, or stay on Node 20.
+4
View File
@@ -48,6 +48,10 @@ open it in the browser and you are done.
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
To update to a new version: `/agenthub:agenthub-update`.
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
<a id="install-terminal"></a>
+7
View File
@@ -48,6 +48,13 @@ trên trình duyệt là xong.
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
Cập nhật bản mới: `/agenthub:agenthub-update`.
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
Gỡ hẳn khỏi máy: **Settings → Hệ thống → Gỡ cài đặt**, hoặc `agenthub uninstall`
(chỉ liệt kê) rồi `agenthub uninstall --yes` — xoá cả database của AgentHub; các cuộc
trò chuyện của Claude Code/Codex, worktree và repository của bạn được giữ lại.
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
<a id="install-terminal"></a>
+345 -14
View File
@@ -40,7 +40,7 @@
const path = require("node:path");
const fs = require("node:fs");
const { spawn } = require("node:child_process");
const { spawn, spawnSync } = require("node:child_process");
// Resolve the repo root relative to this script's REAL location so the CLI
// works both from a checkout (./bin/agenthub.js) and through the global symlink
@@ -1397,14 +1397,36 @@ async function cmdReinstallHooks() {
* built client) and wait until /api/health answers. No-ops with a pointer to
* the live URL when a server is already up. The child is fully detached with
* its output appended to data/agenthub-server.log, so closing this terminal does
* not stop the dashboard; stop it later with `kill <pid>` (the PID is
* printed and registered in ~/.claude/.agenthub.json).
* not stop the dashboard; stop it later with `agenthub stop`.
*/
async function cmdStart(flags) {
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
return;
}
// A plugin install starts the way a Claude Code session does: its bootstrap
// knows the runtime dir, the UI bundle and the autostart unit.
if (/[\\/]\.claude[\\/]plugins[\\/]cache[\\/]/.test(REPO_ROOT)) {
spawnSync(
process.execPath,
[path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"), "--worker"],
{
stdio: "inherit",
}
);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
return;
}
await new Promise((r) => setTimeout(r, 500));
}
console.error(c.red("✖ Server did not become healthy within 30 s — run /agenthub-doctor"));
process.exit(1);
}
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
if (!fs.existsSync(clientDist)) {
console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
@@ -1447,7 +1469,7 @@ async function cmdStart(flags) {
console.log(
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
);
console.log(c.dim(` Stop it with: kill ${child.pid}`));
console.log(c.dim(" Stop it with: agenthub stop"));
return;
}
tick++;
@@ -1463,6 +1485,79 @@ async function cmdStart(flags) {
process.exit(1);
}
/**
* Quit AgentHub (issue #65): every lane pane is killed, the server stops, and
* the plugin bootstrap starts none until `agenthub start` — or a boot/login
* through an enabled autostart unit (`server/lib/quit.js`).
*/
async function cmdStop() {
if (!(await serverIsUp())) {
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).markQuit();
console.log(
`${c.dim("○")} Dashboard is not running — it stays stopped until ${c.bold("agenthub start")}`
);
return;
}
const res = await post("/api/settings/quit", undefined, { allowError: true });
if (res?.status) {
// Server mode: a shell on the host carries no login, and Quit is an admin's.
if (res.data?.error?.code === "EAUTH" || res.data?.error?.code === "EADMIN") {
console.error(
`${c.red("✖ This dashboard runs in server mode: Quit needs an admin login.")}\n` +
" Log in as an admin and use Settings → System → Quit AgentHub, or stop its service:\n" +
` ${c.bold("sudo systemctl stop agenthub")} ${c.dim("# system unit (one Linux user per account)")}\n` +
` ${c.bold("systemctl --user stop agenthub")} ${c.dim("# per-user unit")}`
);
} else {
const msg = res.data?.error?.message || `HTTP ${res.status}`;
console.error(c.red(`✖ POST /api/settings/quit → ${msg}`));
}
process.exit(1);
}
const { killed } = res;
const deadline = Date.now() + 15_000;
while ((await serverIsUp()) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
}
if (await serverIsUp()) {
console.error(c.red(`✖ The dashboard at ${baseUrl()} is still answering after 15 s`));
process.exit(1);
}
console.log(
`${c.green("✔")} AgentHub stopped${killed ? ` — ${killed} terminal session(s) closed` : ""}. Start it again with: ${c.bold("agenthub start")}`
);
}
/**
* Uninstall AgentHub (scripts/uninstall.js): without `--yes` it only lists what
* would go. Needs no server — it is the thing being removed.
*/
function cmdUninstall(flags) {
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
const uninstall = require(path.join(REPO_ROOT, "scripts", "uninstall.js"));
if (flags.yes !== true) {
const steps = uninstall.plan();
if (!steps.length) return console.log(`${c.dim("○")} Nothing of AgentHub's is left here`);
console.log(c.bold("agenthub uninstall --yes would:"));
for (const s of steps) {
console.log(` ${c.red("✖")} ${s.label}`);
for (const i of s.items) console.log(c.dim(` ${i}`));
}
console.log(
c.dim(
" Kept: the AI tools' own conversations, workspace worktrees and branches, your repos."
)
);
console.error(c.red("Nothing was removed. Re-run with --yes: agenthub uninstall --yes"));
process.exitCode = 1;
return;
}
const r = uninstall.run({ onLine: (l) => console.log(l) });
console.log(c.dim(`Log: ${r.log}`));
if (!r.ok) process.exitCode = 1;
}
/**
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
* (macOS) so the dashboard survives reboots without anything (Claude Code
@@ -2731,6 +2826,16 @@ const COMMAND_GROUPS = [
[
["status", "", "Up/down indicator for the dashboard server"],
["start", "[--port N]", "Start the server in the background and wait for healthy"],
[
"stop",
"",
"Quit: close every workspace terminal and stop the server until `agenthub start`",
],
[
"uninstall",
"[--yes]",
"Remove AgentHub from this machine, its database included (lists first without --yes)",
],
[
"service",
"install|uninstall|status [--port N]",
@@ -2744,12 +2849,12 @@ const COMMAND_GROUPS = [
[
"users",
"list|add|passwd|role|disable|enable|assign|audit [name]",
"Server mode's accounts (add: --role admin|analyst|member)",
"Server mode's accounts (add: --role admin|analyst|developer)",
],
[
"accounts",
"list|add|remove [name]",
"Claude account pool: new sessions spread over these accounts",
"list|add|remove [name] [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
@@ -2948,6 +3053,11 @@ const COMMAND_GROUPS = [
"",
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
],
[
"tray",
"",
"Show AgentHub's icon in the OS status area now (the server keeps it up; Settings → System hides it)",
],
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
[
"statusline install|uninstall",
@@ -3842,6 +3952,23 @@ async function cmdServer(sub, flags) {
);
return;
}
if (sub === "install-system") return installSystem(flags, settingsEnv);
if (sub === "tools") {
// S3/S4: copy this install's scripts and every installed AI tool under
// /opt/agenthub, where every account's pane runs them (the server also
// does this at start and after each install/update).
const shared = require(path.join(REPO_ROOT, "server", "lib", "shared-tools.js"));
await shared.publishApp(REPO_ROOT);
const rows = await shared.publishAll();
table(
["tool", "shared copy"],
rows.map((r) => [
r.id,
r.error ? c.red(r.error) : `${r.path}${r.copied ? "" : c.dim(" (unchanged)")}`,
])
);
return;
}
if (sub === "off") {
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
envNote("AGENTHUB_MODE");
@@ -3859,11 +3986,120 @@ async function cmdServer(sub, flags) {
return;
}
console.error(
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
c.red(
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
)
);
process.exitCode = 1;
}
/**
* `agenthub server install-system` — one Linux user per account (S3,
* docs/SERVER-MODE.md). Run as the HOST account, never as root: it resolves
* this install's paths as the host, hands them to the root half
* (`scripts/server/install-system.sh`) through ONE `sudo`, points the system
* unit at this install, and moves the dashboard from the `systemd --user`
* unit to the system one (panes survive: KillMode=process). `--uninstall`
* goes back to the user unit.
*/
async function installSystem(flags, settingsEnv) {
const os = require("node:os");
if (process.getuid && process.getuid() === 0) {
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
}
const which = (bin) =>
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
const host = os.userInfo().username;
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
const data = getDataDir();
const port = String(process.env.DASHBOARD_PORT || "4820");
const script = path.join(REPO_ROOT, "scripts", "server", "install-system.sh");
const args = flags.uninstall
? [script, "--uninstall", "--host", host]
: [
script,
...["--host", host, "--node", process.execPath, "--root", REPO_ROOT, "--data", data],
...["--port", port, "--tmux", which("tmux"), "--git", which("git")],
...(which("gh") ? ["--gh", which("gh")] : []),
];
if (flags["dry-run"]) {
console.log(["sudo", "bash", ...args].join(" "));
return;
}
const userUnit = (...a) => spawnSync("systemctl", ["--user", ...a, "agenthub.service"]);
// What either unit runs: this install's node and server, with its env.
const runtime = path.join(data, "runtime");
const clientDist = fs.existsSync(path.join(REPO_ROOT, "client", "dist", "index.html"))
? path.join(REPO_ROOT, "client", "dist")
: path.join(runtime, "client-dist");
const env = { NODE_ENV: "production", PATH: process.env.PATH || "", DASHBOARD_PORT: port };
env.DASHBOARD_CLIENT_DIST = clientDist;
if (!fs.existsSync(path.join(REPO_ROOT, "node_modules"))) {
env.NODE_PATH = path.join(runtime, "node_modules");
}
const serverArgv = [process.execPath, path.join(REPO_ROOT, "server", "index.js")];
if (flags.uninstall) {
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
throw new Error("install-system --uninstall failed");
}
// Repoint the user unit first: the build it named may have been pruned
// while the system unit was the one protected.
require(path.join(REPO_ROOT, "scripts", "systemd-service.js")).writeUserService({
argv: serverArgv,
env,
});
userUnit("enable", "--now");
console.log(`${c.green("✔")} back on the systemd --user unit`);
return;
}
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
throw new Error("install-system failed — nothing was started");
}
// The unit reads node, the plugin root and the env from a file this account
// owns (the bootstrap repoints it after an update without root).
const sys = require(path.join(REPO_ROOT, "scripts", "systemd-system-service.js"));
const wrote = sys.writeUserService({ argv: serverArgv, env });
if (wrote === "failed" || wrote === "unavailable") {
throw new Error(`could not write ${sys.envPath()} (${wrote})`);
}
settingsEnv.writeSetting(
"AGENTHUB_USERS_ROOT",
require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js")).serverConf().usersRoot
);
// Hand over: the user unit stops (its panes stay), and so does a dashboard
// started without one (detached) — else the system unit finds the port
// taken and the old process, without the S3 groups, keeps serving.
userUnit("disable", "--now");
const boot = require(path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"));
const sysUp = () =>
spawnSync("systemctl", ["is-active", "agenthub.service"], {
encoding: "utf8",
}).stdout.trim() === "active";
// A re-run while the system unit serves: that server is the one to keep.
if (!sysUp()) boot.stopDashboard();
for (let i = 0; i < 50 && !sysUp() && boot.livePids().length; i++) {
spawnSync("sleep", ["0.2"]);
}
if (!sysUp() && boot.livePids().length) {
throw new Error("a dashboard is still running — stop it first");
}
if (spawnSync("sudo", ["-n", "systemctl", "enable", "agenthub.service"]).status !== 0) {
console.log(c.yellow("! could not enable agenthub.service for boot"));
}
if (spawnSync("sudo", ["-n", "systemctl", "start", "--no-block", "agenthub.service"]).status) {
throw new Error("could not start agenthub.service — `sudo systemctl status agenthub`");
}
spawnSync("sleep", ["3"]);
if (!sysUp()) {
throw new Error("agenthub.service did not stay up — `sudo journalctl -u agenthub -n 50`");
}
console.log(`${c.green("✔")} the dashboard now runs as the system unit agenthub.service`);
console.log(" Give the existing users their Linux accounts: agenthub users provision --all");
console.log(
c.dim(" Log out and back in once: your own session joins groups agenthub/agenthub-proc.")
);
}
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
async function cmdUsers(sub, positional, flags) {
const { auth } = serverAuth();
@@ -3881,7 +4117,7 @@ async function cmdUsers(sub, positional, flags) {
return;
case "add": {
need();
const role = typeof flags.role === "string" ? flags.role : "member";
const role = typeof flags.role === "string" ? flags.role : "developer";
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
return;
@@ -3916,6 +4152,37 @@ async function cmdUsers(sub, positional, flags) {
);
return;
}
case "provision": {
// S3: the Linux account of a user made before install-system (or after a
// failed create). Idempotent: an existing member account is kept.
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
if (!linux.s3Enabled()) {
throw new Error(
"one Linux user per account is not installed (agenthub server install-system)"
);
}
const names = flags.all
? auth
.listUsers()
.filter((u) => !u.disabled)
.map((u) => u.username)
: (need(), [name]);
const memberClaude = require(path.join(REPO_ROOT, "server", "lib", "member-claude.js"));
for (const n of names) {
// One user that cannot be provisioned (a name Linux refuses, a folder
// already there) is reported and skipped — never the end of the run.
try {
console.log(`${c.green("✔")} ${linux.provision("create", n)}`);
} catch (e) {
console.error(c.red(`✖ ${n}: ${e.message}`));
process.exitCode = 1;
continue;
}
// Opt-in plugin baseline (/etc/agenthub/plugin-baseline.json); prints per entry.
await memberClaude.applyPluginBaseline(n);
}
return;
}
case "disable":
case "enable":
need();
@@ -3925,21 +4192,75 @@ async function cmdUsers(sub, positional, flags) {
default:
console.error(
c.red(
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|member] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | provision <name>|--all | assign <workspace id> <name> | audit [--limit N]"
)
);
process.exitCode = 1;
}
}
/** `agenthub accounts … --tool codex` — the Codex account pool (server/lib/codex-pool.js). */
async function cmdCodexAccounts(sub, name, flags) {
const pool = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
if (sub === "add") {
let added;
if (flags["api-key"]) {
added = pool.addAccount(name, { apiKey: await askPassword(`OpenAI API key for ${name}: `) });
} else if (typeof flags.from === "string") {
// A `codex login` home (or its auth.json) — read here, on the host's shell.
const from = path.resolve(flags.from);
const file = fs.statSync(from).isDirectory() ? path.join(from, "auth.json") : from;
added = pool.addAccount(name, { auth: JSON.parse(fs.readFileSync(file, "utf8")) });
console.log(c.dim(` You can delete ${file} now; the pool keeps its own copy.`));
} else {
throw new Error(
"usage: agenthub accounts add <name> --tool codex --api-key | --from <dir>\n" +
" A ChatGPT account: `CODEX_HOME=$(mktemp -d) codex login` signed in as it, then --from that dir."
);
}
console.log(`${c.green("✔")} ${added.name} (${added.kind}) added`);
console.log(c.dim(" New Codex sessions now spread over the pool's accounts."));
return;
}
if (sub === "remove") {
pool.removeAccount(name);
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
return;
}
if (sub !== undefined && sub !== "list") {
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool codex");
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
pool
.poolStatus()
.map((a) => [
a.name,
a.kind,
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
a.quota
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–",
a.full ? "full" : "ok",
])
);
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional) {
async function cmdAccounts(sub, positional, flags = {}) {
serverAuth(); // the same settings + database the server reads
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
const name = positional[0];
if (sub === "add" || sub === "remove") {
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
}
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex");
}
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") {
console.log(
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
@@ -3979,9 +4300,13 @@ async function cmdAccounts(sub, positional) {
: "ok",
])
);
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
return;
}
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
);
process.exitCode = 1;
}
@@ -4000,6 +4325,10 @@ async function runCommand(argv) {
return cmdStatus();
case "start":
return cmdStart(flags);
case "stop":
return cmdStop();
case "uninstall":
return cmdUninstall(flags);
case "service":
return cmdService(positional[0], flags);
case "server":
@@ -4007,7 +4336,7 @@ async function runCommand(argv) {
case "users":
return cmdUsers(positional[0], positional.slice(1), flags);
case "accounts":
return cmdAccounts(positional[0], positional.slice(1));
return cmdAccounts(positional[0], positional.slice(1), flags);
case "stats":
return cmdStats();
case "kanban":
@@ -4135,6 +4464,8 @@ async function runCommand(argv) {
return;
}
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
case "tray":
return require(path.join(REPO_ROOT, "scripts", "tray.js")).main();
case "open":
return cmdOpen();
case "version":
+3 -3
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
@@ -199,7 +199,7 @@ client/
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
│ │ │ ├── ReportSettings.tsx # Settings → Snapshots & reports: scheduled report definitions, Run now, run history + downloads
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │
@@ -259,7 +259,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
Settings[SettingsHub /settings/:section<br/>General: Appearance · Notifications · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · Git (credentials, worktree suggestions)]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings
+9 -9
View File
@@ -30,7 +30,7 @@
<title>AgentHub - SmartGift Solution</title>
<meta
name="description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta
name="keywords"
@@ -45,23 +45,23 @@
<meta property="og:title" content="AgentHub - SmartGift Solution" />
<meta
property="og:description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta property="og:image" content="/og-image.svg" />
<meta property="og:image" content="/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
<meta
name="twitter:description"
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/>
<meta name="twitter:image" content="/og-image.svg" />
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta name="twitter:image" content="/og-image.png" />
<meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<meta name="twitter:creator" content="@smartgift" />
<!-- JSON-LD structured data -->
@@ -70,7 +70,7 @@
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "AgentHub",
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
"description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
"softwareVersion": "1.0.0",
+629 -548
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+9
View File
@@ -87,6 +87,7 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
function PageFallback() {
const { t } = useTranslation();
@@ -209,6 +210,14 @@ export default function App() {
</Page>
}
/>
<Route
path="users"
element={
<Page>
<Users />
</Page>
}
/>
<Route
path="settings/*"
element={
@@ -93,6 +93,7 @@ import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import { useAiTools } from "../hooks/useAiTools";
import { useSpin } from "../hooks/useSpin";
import { usePermissions } from "./AuthGate";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -284,7 +285,9 @@ export function AlertsNotifications() {
const { t } = useTranslation("alerts");
const { t: ts } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("rules");
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
const { isAdmin } = usePermissions();
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
// Rules
const [rules, setRules] = useState<AlertRule[]>([]);
@@ -361,8 +364,8 @@ export function AlertsNotifications() {
}, [unackedOnly, alerts.length, t]);
useEffect(() => {
loadRules();
}, [loadRules]);
if (isAdmin) loadRules();
}, [loadRules, isAdmin]);
useEffect(() => {
loadAlerts();
@@ -504,7 +507,7 @@ export function AlertsNotifications() {
(form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{
key: "rules",
label: ts("alertsHub.tabRules"),
@@ -519,6 +522,7 @@ export function AlertsNotifications() {
badge: unacked || undefined,
},
];
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
return (
<div className="space-y-4">
@@ -1086,7 +1090,7 @@ export function AlertsNotifications() {
)}
</p>
</div>
{!alert.acknowledged_at && (
{!alert.acknowledged_at && alert.can_ack !== false && (
<button
onClick={() => onAck(alert.id)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
+131 -33
View File
@@ -6,7 +6,11 @@
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
* someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
* `usePermissions()` says what that user's role may do, so pages hide the rest.
* Once an account is known (local mode is one), its own UI preferences are
* loaded into this browser (`lib/userPrefs.ts`).
* The login page follows the OS's light/dark (`applySystemTheme`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,12 +18,17 @@ import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { BRAND_LOGO } from "../lib/brand";
import { applySystemTheme } from "../hooks/useTheme";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import { hydrate } from "../lib/userPrefs";
type AuthValue = { state: AuthState; logout: () => Promise<void> };
@@ -28,6 +37,29 @@ const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout:
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
export const useAuth = () => useContext(AuthContext);
/**
* What the logged-in user may do — the server's rules (server/lib/auth.js,
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
* only their own — the one that matters is an analyst looking at another
* user's.
*/
export function usePermissions() {
const { state } = useAuth();
const user = state.mode === "server" ? state.user : null;
const local = state.mode !== "server";
const isAdmin = local || user?.role === "admin";
return {
isAdmin,
seesAll: local || (!!user && user.role !== "developer"),
// A developer only ever sees their own rows, so everything shown is theirs.
canEditLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
};
}
/** The mode this browser last saw, so a local dashboard paints at once. */
const MODE_KEY = "agenthub.authMode";
const RETRY_MS = 2000;
@@ -90,6 +122,18 @@ export function AuthGate({ children }: { children: ReactNode }) {
};
}, []);
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
// per account, so a second login in this browser brings that account's.
const account =
state?.mode === "local"
? "local"
: state?.mode === "server" && state.user
? `u${state.user.id}`
: null;
useEffect(() => {
if (account) void hydrate();
}, [account]);
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
@@ -105,9 +149,18 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Nobody is signed in, so no account's theme applies: follow the OS, live.
useLayoutEffect(() => {
applySystemTheme();
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
mq?.addEventListener?.("change", applySystemTheme);
return () => mq?.removeEventListener?.("change", applySystemTheme);
}, []);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
@@ -125,38 +178,83 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.username")}</span>
<input
className="input w-full"
autoComplete="username"
autoFocus
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.password")}</span>
<input
className="input w-full"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
<button type="submit" className="btn-primary w-full" disabled={busy}>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
<form
onSubmit={submit}
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
>
{/* Accent glow and a grid fading out from the top, drawn from the
theme's own accent so it holds in every theme. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-56"
style={{
backgroundImage:
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
backgroundPosition: "center top",
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
}}
/>
<div className="relative space-y-5">
<div className="text-center">
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
{t("auth.title")}
</h1>
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
</div>
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
<span>{t("auth.username")}</span>
<input
className="input h-11 w-full px-4"
autoComplete="username"
autoFocus
required
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
<label htmlFor="login-password">{t("auth.password")}</label>
<div className="relative">
<input
id="login-password"
className="input h-11 w-full pl-4 pr-11"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{/* Inverted fill (text colour behind the surface colour): black in a
light theme, light in a dark one, like the reference design. */}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={busy}
>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
</div>
</form>
</main>
);
+44
View File
@@ -0,0 +1,44 @@
/**
* @file ImageLightbox.tsx
* @description One image shown full size over the whole page; a click or
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
* layer or a header drawn over a pane can cover it — opened from inside a
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
* layer, under anything stacked above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { createPortal } from "react-dom";
export function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal(
<div
role="dialog"
aria-label={alt}
data-testid="image-lightbox"
onClick={onClose}
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={src}
alt={alt}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>,
document.body
);
}
+4
View File
@@ -83,6 +83,8 @@ import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
import { OnboardingTour } from "./onboarding/OnboardingTour";
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
@@ -172,6 +174,8 @@ export function Layout({ wsConnected }: LayoutProps) {
</a>
<UpdateNotifier />
<SagiMount />
<OnboardingWizard />
<OnboardingTour />
{drawer && drawerOpen && (
<div
aria-hidden
+5 -1
View File
@@ -12,7 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
@@ -27,12 +27,15 @@ export function PathPickButton({
start,
onPick,
showLabel = false,
buttonRef,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
/** Lets a field beside it open the same picker (a click on an empty path). */
buttonRef?: Ref<HTMLButtonElement>;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
@@ -61,6 +64,7 @@ export function PathPickButton({
return (
<>
<button
ref={buttonRef}
type="button"
onClick={pick}
disabled={busy}
+49 -39
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { usePermissions } from "../AuthGate";
import { toast } from "../../lib/toast";
const ITEM =
@@ -50,6 +51,8 @@ export function PetMenu({
const bundleInput = useRef<HTMLInputElement>(null);
const [choosing, setChoosing] = useState(false);
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Adding or deleting a pet changes the whole install: an admin's.
const { isAdmin } = usePermissions();
// Closes on a press outside the menu, or Escape.
useEffect(() => {
@@ -133,6 +136,7 @@ export function PetMenu({
</button>
{list?.featured.map((f) => {
const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return (
<button
key={f.id}
@@ -173,48 +177,54 @@ export function PetMenu({
<span className="min-w-0 truncate">{p.displayName}</span>
{mark(p.id)}
</button>
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{isAdmin && (
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
{isAdmin && (
<>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
</>
)}
<a
href="https://codex-pets.net/"
target="_blank"
+67 -39
View File
@@ -1,14 +1,16 @@
/**
* @file PetSprite.tsx
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
* animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review) — played one row at a time by Sagi's mood; an
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
* one per animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
* timings; a moment (waving, jumping, failed, review) plays three times and
* settles into idle, a condition loops. An `action` (dragged left/right,
* hovered) plays the run-left/run-right or waving row over the mood's, like
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
* uploaded image is shown still and bobs with the same `data-mood` CSS as
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
* or waving row over the mood's, like Codex's own pet. Each row steps at its
* own pace and holds its last frame longer, so a loop breathes instead of
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls
* back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -19,9 +21,29 @@ import { api } from "../../lib/api";
import type { Mood } from "./brain";
const CELL = { w: 192, h: 208 };
const SHEET = { cols: 8, rows: 9 };
/** Frames in each row of the Codex atlas. */
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
const COLS = 8;
/** `n` frames of `step` ms, the last one `last` ms. */
const frames = (n: number, step: number, last: number) =>
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
* how a sleeping pet breathes here. */
const DURATIONS = [
[280, 110, 110, 140, 140, 320], // idle
frames(8, 120, 220), // run right
frames(8, 120, 220), // run left
frames(4, 140, 280), // waving
frames(5, 140, 280), // jumping
frames(8, 140, 240), // failed
frames(6, 150, 260), // waiting
frames(6, 120, 220), // running (working)
frames(6, 150, 280), // review
];
const SLEEP_SLOWDOWN = 6;
/** Rows that play three times and settle into idle; the others loop. */
const MOMENTS = new Set([3, 4, 5, 8]);
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
const GAZE_ROW = 9;
/** Which row plays for each mood. */
const ROW: Record<Mood, number> = {
idle: 0,
@@ -36,25 +58,13 @@ const ROW: Record<Mood, number> = {
/** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
const PACE: [number, number][] = [
[220, 1400], // idle: a breath, then a pause
[120, 120], // run right — keeps up with the drag
[120, 120], // run left
[180, 400], // waving
[160, 700], // jumping
[200, 1200], // failed
[220, 900], // waiting
[170, 400], // running (working)
[220, 900], // review
];
const SLEEP_PACE: [number, number] = [450, 2000];
export function PetSprite({
id,
kind,
mood,
action,
gaze,
size,
reducedMotion,
onBroken,
@@ -63,6 +73,8 @@ export function PetSprite({
kind: "codex" | "image";
mood: Mood;
action?: PetAction | null;
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
gaze?: number | null;
size: number;
reducedMotion: boolean;
onBroken: () => void;
@@ -70,24 +82,30 @@ export function PetSprite({
const url = api.pets.spriteUrl(id);
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
const sleeping = !action && mood === "sleeping";
const [frame, setFrame] = useState(0);
// The cell on show, tagged with the row it was played for: a cell left over
// from the previous row (say column 7 of a run) is never drawn on the next.
const [cell, setCell] = useState({ of: row, row, col: 0 });
const [rows, setRows] = useState(9);
useEffect(() => {
setFrame(0);
setCell({ of: row, row, col: 0 });
if (kind !== "codex" || reducedMotion) return;
const count = FRAMES[row] ?? 1;
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
let f = 0;
const play = (r: number, slow = 1) =>
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
let i = 0;
let timer = 0;
// `i` always indexes `list`: it wraps at its end.
const tick = () => {
timer = window.setTimeout(
() => {
f = (f + 1) % count;
setFrame(f);
tick();
},
f === count - 1 ? hold : step
);
timer = window.setTimeout(() => {
if (++i === list.length) {
i = 0;
if (MOMENTS.has(row)) list = play(0);
}
setCell(list[i]!);
tick();
}, list[i]!.ms);
};
tick();
return () => window.clearTimeout(timer);
@@ -96,10 +114,13 @@ export function PetSprite({
// A background image reports no error of its own: probe it once.
useEffect(() => {
if (kind !== "codex") return;
// It also tells a v2 sheet (11 rows) from a v1 (9).
const probe = new Image();
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
probe.onerror = onBroken;
probe.src = url;
return () => {
probe.onload = null;
probe.onerror = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -120,11 +141,18 @@ export function PetSprite({
/>
);
}
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
const shown =
pose != null
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
: cell.of === row
? cell
: { row, col: 0 };
const scale = size / CELL.h;
return (
<div
data-testid="pet-sprite"
data-row={row}
data-row={shown.row}
aria-hidden
style={{
width: Math.round(CELL.w * scale),
@@ -132,8 +160,8 @@ export function PetSprite({
margin: "0 auto",
backgroundImage: `url("${url}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
}}
/>
);
+30 -1
View File
@@ -8,7 +8,8 @@
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
* bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path: an unmatched Ask query starts a claude session in
@@ -244,6 +245,32 @@ function SagiBody() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
const btn = useRef<HTMLButtonElement>(null);
const [gaze, setGaze] = useState<number | null>(null);
const wearsSheet = "kind" in pet && pet.kind === "codex";
useEffect(() => {
if (!wearsSheet || reducedMotion) return;
let rest = 0;
const onMove = (e: PointerEvent) => {
const r = btn.current?.getBoundingClientRect();
if (!r) return;
const deg =
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
Math.PI;
setGaze(Math.round((deg + 360) / 22.5) % 16);
window.clearTimeout(rest);
rest = window.setTimeout(() => setGaze(null), 2000);
};
window.addEventListener("pointermove", onMove);
return () => {
window.removeEventListener("pointermove", onMove);
window.clearTimeout(rest);
};
}, [wearsSheet, reducedMotion]);
const onNavigate = useCallback(
(route: string) => {
navigate(route);
@@ -334,6 +361,7 @@ function SagiBody() {
)}
<button
ref={btn}
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }}
@@ -368,6 +396,7 @@ function SagiBody() {
mood={brain.mood}
// Dragged → runs that way; hovered → waves (Codex's pet does both).
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
gaze={gaze}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
act(() => vi.advanceTimersByTime(50));
act(() => vi.advanceTimersByTime(110));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
act(() => vi.advanceTimersByTime(10));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
});
it("holds a loop's last frame before starting over", () => {
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
render(
<PetSprite
id="cp-goku"
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
/>
);
const sprite = screen.getByTestId("pet-sprite");
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(1000));
act(() => vi.advanceTimersByTime(310));
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(400));
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
expect(sprite.style.backgroundPosition).toBe("0px 0px");
});
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
act(() => vi.advanceTimersByTime(3 * 840 - 1));
expect(sprite.dataset.row).toBe("4");
act(() => vi.advanceTimersByTime(1));
expect(sprite.dataset.row).toBe("0");
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
act(() => vi.advanceTimersByTime(10_000));
expect(sprite.dataset.row).toBe("7");
});
it("looks toward the pointer on a v2 sheet, only while idle", () => {
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
vi.stubGlobal(
"Image",
class {
naturalHeight = 2288;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
this.onload?.();
}
}
);
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
// Pose 12 (270°, left) = row 10, column 4.
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
expect(sprite.dataset.row).toBe("7");
vi.unstubAllGlobals();
});
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
gaze={4}
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
+15 -4
View File
@@ -3,7 +3,8 @@
* @description Generic styled dropdown that replaces native `<select>` elements
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
* checkmark space inconsistently; this control aligns labels, flips above the
* trigger when viewport space is tight, and marks the chosen option with accent
* trigger when space is tight — in its split pane (`data-menu-bounds`, see
* `menuRoom`) or else the viewport — and marks the chosen option with accent
* color plus a Lucide check.
*
* ## Accessibility — WAI-ARIA "listbox button"
@@ -74,6 +75,14 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
* over anything crossing its edge — else the viewport. */
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
}
/** Single option in a {@link Select} list. */
export interface SelectOption<T extends string> {
/** Stored value emitted via `onChange`. */
@@ -144,9 +153,11 @@ export function Select<T extends string>({
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const below = window.innerHeight - rect.bottom;
setOpenUp(below < 288 && rect.top > below);
const { above, below } = menuRoom(
buttonRef.current,
buttonRef.current.getBoundingClientRect()
);
setOpenUp(below < 288 && above > below);
}
listRef.current?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
+32 -23
View File
@@ -70,10 +70,12 @@ import {
ChevronDown,
FolderTree,
LogOut,
UsersRound,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "./AuthGate";
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
import { useAuth, usePermissions } from "./AuthGate";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -87,13 +89,6 @@ const NAV_KEYS = [
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
// owned by the brand, not vendored here — a machine with no network shows the
// alt text instead.
const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
@@ -191,6 +186,8 @@ export function Sidebar({
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
// Checking for (and applying) an update acts on the host: an admin's.
const canUpdate = usePermissions().isAdmin;
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -464,7 +461,15 @@ export function Sidebar({
user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
{[
...NAV_KEYS,
// Server mode: the Users page, for an admin or analyst.
...(auth.state.mode === "server" &&
auth.state.user &&
auth.state.user.role !== "developer"
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
: []),
].map(({ to, icon: Icon, key }) => {
const label = t(key);
return (
<NavLink
@@ -485,6 +490,7 @@ export function Sidebar({
<button
type="button"
onClick={onToggle}
data-testid="sidebar-tree-rail"
title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
@@ -584,7 +590,7 @@ export function Sidebar({
checking,
available: updateAvailable,
failed: checkError,
onCheck: onCheckUpdates,
onCheck: canUpdate ? onCheckUpdates : undefined,
}}
onResetStats={() => {
eventCountRef.current = 0;
@@ -626,7 +632,8 @@ interface ConnectionStatusModalProps {
checking: boolean;
available: boolean;
failed: boolean;
onCheck: () => void;
/** Absent for a user who may not update (server mode, not an admin). */
onCheck?: () => void;
};
onResetStats: () => void;
}
@@ -768,18 +775,20 @@ function ConnectionStatusModal({
{update.title}
</p>
</div>
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
{update.onCheck && (
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
)}
</div>
</Section>
-19
View File
@@ -27,7 +27,6 @@
* ## Public surface
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
@@ -52,11 +51,6 @@
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TextSkeleton**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TableRowSkeleton**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
}
/**
* Single-line text placeholder. Good for trend/sub labels.
*/
export function TextSkeleton({
className = "",
width = "w-16",
}: {
className?: string;
width?: string;
}) {
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
}
/**
* Full table-row skeleton for tabular pages. Caller passes the column count so
* the skeleton lines up under existing headers.
+25 -3
View File
@@ -35,7 +35,15 @@ import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
};
function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
@@ -123,6 +131,7 @@ const QUOTA_ERROR_KEY: Record<string, string> = {
ELIVEEXPIRED: "statusBar.quotaExpired",
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
};
/** One account: its name, sign-in line, then a row per window with its reset. */
@@ -133,6 +142,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
<div data-testid="status-bar-quota-account" className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{q.label || q.account}</span>
{q.plan && (
<span
data-testid="status-bar-quota-plan"
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
>
{q.plan}
</span>
)}
{many && q.active !== false && (
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
{t("statusBar.quotaActive")}
@@ -146,11 +163,16 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
</div>
) : (
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
{q.windows.map((w) => {
{q.windows.map((w, i) => {
const p = Math.round(w.usedPercent ?? 0);
const resets = untilText(w.resetsAt);
return (
<Fragment key={w.label}>
<Fragment key={`${w.group ?? ""}-${w.label}`}>
{w.group && w.group !== q.windows[i - 1]?.group && (
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
{w.group}
</span>
)}
<span className="text-fg-muted">
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
</span>
+4
View File
@@ -88,6 +88,7 @@ import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { usePermissions } from "./AuthGate";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
@@ -125,6 +126,7 @@ function loadDismissedSha(): string | null {
* @returns `null` when no update is available or this release was dismissed.
*/
export function UpdateNotifier() {
const { isAdmin } = usePermissions();
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
@@ -190,7 +192,9 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
+110 -42
View File
@@ -9,7 +9,11 @@
* lane's status or session moves (at most every 3 s) and handed over by the
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`).
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (`owner_name`; yours
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -25,10 +29,11 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
import { useAuth, usePermissions } from "./AuthGate";
import { useAiTools } from "../hooks/useAiTools";
import { useThrottledValue } from "../hooks/useThrottledValue";
import {
@@ -71,6 +76,11 @@ export function WorkspaceTree() {
const location = useLocation();
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes();
const { state: auth } = useAuth();
const { seesAll, canEditLane } = usePermissions();
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
// Server mode staff see every user's workspaces, so the list says whose.
const grouped = auth.mode === "server" && seesAll;
const prefs = useProjectTreePrefs();
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [query, setQuery] = useState("");
@@ -132,10 +142,17 @@ export function WorkspaceTree() {
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
[lanes, order, toolFilter]
);
const sorted = useMemo(() => {
const list = sortByStoredOrder(lanes, order).filter(
(l) => !toolFilter || l.tool === toolFilter
);
if (!grouped) return list;
// Stable: each owner's workspaces keep the stored order inside the group.
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
return [...list].sort(
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
);
}, [lanes, order, toolFilter, grouped, meId]);
const q = query.trim().toLowerCase();
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -312,42 +329,90 @@ export function WorkspaceTree() {
)}
<div ref={listRef} className="space-y-px px-1">
{sorted.map((lane, i) => (
<div key={lane.id} className="relative">
{dropAt === i && <DropLine />}
{visible(lane) && (
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
))}
{sorted.map((lane, i) => {
const owner = ownerKey(lane);
// A search opens every group, like it opens every workspace.
const folded = grouped && !q && !!prefs.collapsedOwners[owner];
const readOnly = !canEditLane(lane);
const ownerLabel =
lane.owner_id == null
? t("tree.noOwner")
: lane.owner_id === meId
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
: (lane.owner_name ?? `#${lane.owner_id}`);
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
return (
<div key={lane.id} className="relative">
{groupStart && groupShown && (
<button
type="button"
data-testid={`workspace-owner-${owner}`}
aria-expanded={!folded}
title={t("tree.ownerToggle", { name: ownerLabel })}
onClick={() =>
writeProjectTreePrefs({
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
})
}
className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
>
{folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<span
aria-hidden
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{lane.owner_name?.[0] ?? "?"}
</span>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
</span>
</button>
)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
<WorkspaceRow
lane={lane}
index={i}
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
// A read-only workspace has no home to open: it only unfolds.
if (readOnly) return setExpanded(lane.id, !expanded(lane.id));
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && !folded && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
readOnly={readOnly}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
);
})}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
@@ -400,6 +465,9 @@ export function WorkspaceTree() {
);
}
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
@@ -0,0 +1,60 @@
/**
* @file AlertsNotifications.roles.test.tsx
* @description Server mode, not an admin: alert rules and channels fire for
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
* Acknowledge only on alerts the server marks theirs (`can_ack`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const rulesList = vi.fn(async () => ({ rules: [] }));
const alert = (id: number, can_ack: boolean) => ({
id,
rule_id: "r1",
rule_name: `rule ${id}`,
rule_type: "error_rate",
session_id: null,
agent_id: null,
message: `boom ${id}`,
details: null,
triggered_at: "2026-10-08T00:00:00.000Z",
acknowledged_at: null,
can_ack,
});
vi.mock("../../lib/api", () => ({
api: {
alerts: {
rules: { list: () => rulesList() },
list: vi.fn(async () => ({
alerts: [alert(1, true), alert(2, false)],
total: 2,
unacked: 2,
})),
ack: vi.fn(),
ackAll: vi.fn(),
},
},
}));
vi.mock("../AuthGate", () => ({
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
}));
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications for a non-admin", () => {
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
expect(await screen.findByText("boom 2")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
expect(rulesList).not.toHaveBeenCalled();
});
});
@@ -2,29 +2,60 @@
* @file AuthGate.test.tsx
* @description The login gate: local mode renders the app untouched, server
* mode shows the login page until a login succeeds, a wrong password says so,
* and a later 401 `EAUTH` brings the login page back.
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
* analyst reads everything but works only in their own workspaces, a
* developer only ever sees (and so changes) their own. The login page wears the
* OS's light/dark; a sign-in swaps in the account's saved appearance.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
const me = vi.fn();
const login = vi.fn();
const prefs = vi.fn();
const LANES = [
{ id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 },
];
vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
},
}));
import { AuthGate } from "../AuthGate";
import { AuthGate, usePermissions } from "../AuthGate";
import { useWorkLanes } from "../../hooks/useLanes";
const user = { id: 1, username: "mia", role: "member" };
function Probe() {
const p = usePermissions();
const { lanes } = useWorkLanes();
return (
<p>
{[
`admin:${p.isAdmin}`,
`all:${p.seesAll}`,
`mine:${p.canEditLane({ owner_id: 5 })}`,
`theirs:${p.canEditLane({ owner_id: 6 })}`,
`work:${lanes.map((l) => l.id).join(",")}`,
].join(" ")}
</p>
);
}
const user = { id: 1, username: "mia", role: "developer" };
beforeEach(() => {
me.mockReset();
login.mockReset();
prefs.mockReset().mockResolvedValue({ prefs: {} });
localStorage.clear();
});
@@ -80,15 +111,15 @@ describe("AuthGate", () => {
render(<AuthGate>app</AuthGate>);
const name = await screen.findByLabelText(/username/i);
const pass = screen.getByLabelText(/password/i);
const pass = screen.getByLabelText(/^password$/i);
await userEvent.type(name, "mia");
await userEvent.type(pass, "nope");
await userEvent.click(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
await userEvent.type(pass, "member-pass-1");
await userEvent.click(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText("app")).toBeInTheDocument();
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
@@ -98,3 +129,63 @@ describe("AuthGate", () => {
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
});
});
describe("AuthGate theme", () => {
it("wears the OS theme while signed out and the account's own once signed in", async () => {
const html = document.documentElement;
// The previous user's choice, left in this browser.
localStorage.setItem("theme", "dark");
localStorage.setItem("agenthub.theme.dark", "darcula");
const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
vi.stubGlobal("matchMedia", () => mq); // the OS is light
me.mockResolvedValue({ mode: "server", user: null });
prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } });
login.mockResolvedValue({ user });
render(<AuthGate>app</AuthGate>);
await userEvent.type(await screen.findByLabelText(/username/i), "mia");
expect(html.dataset.mode).toBe("light");
expect(html.hasAttribute("data-theme")).toBe(false);
await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => expect(html.dataset.theme).toBe("orca-dark"));
expect(html.dataset.mode).toBe("dark");
expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark");
vi.unstubAllGlobals();
});
});
describe("usePermissions / useWorkLanes", () => {
const as = async (state: unknown) => {
cleanup();
me.mockResolvedValue(state);
render(
<AuthGate>
<Probe />
</AuthGate>
);
return (await screen.findByText(/work:\d/)).textContent;
};
it("lets an admin and local mode do everything", async () => {
for (const state of [
{ mode: "local" },
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
]) {
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
}
});
it("lets an analyst read everything but work only in their own workspaces", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
"admin:false all:true mine:true theirs:false work:1"
);
});
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
"admin:false all:false mine:true theirs:true work:1,2"
);
});
});
@@ -2,7 +2,8 @@
* @file Select.test.tsx
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
* listbox/option roles with aria-selected, aria-activedescendant tracking,
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
* Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
* list that flips up by the room in its split pane, not the window's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
fireEvent.click(screen.getByText("Apple"));
expect(onChange).toHaveBeenCalledWith("a");
});
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
render(
<div data-menu-bounds data-testid="pane">
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
</div>
);
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
const button = screen.getByRole("button", { name: "Fruit" });
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
vi.stubGlobal("innerHeight", 2000);
fireEvent.click(button);
expect(screen.getByRole("listbox").className).toContain("bottom-full");
vi.unstubAllGlobals();
});
});
@@ -24,6 +24,7 @@ function today() {
vi.mock("../../lib/api", () => ({
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
sessions: {
// 3 active sessions, 1 of them waiting on the user.
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
active: true,
label: "Work",
account: "me@example.com",
plan: "max",
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
windows: [
{
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
windows: [],
error: "EUPSTREAM",
},
{
id: "antigravity",
active: true,
windows: [
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
],
},
{
id: "cursor",
active: true,
account: "c@example.com",
plan: "pro",
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
},
],
checkedAt: "",
}),
@@ -214,8 +232,8 @@ describe("StatusBar", () => {
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
await user.hover(chips);
const panel = screen.getByTestId("status-bar-quota-panel");
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workin use");
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workmaxin use");
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
expect(work).toHaveTextContent("5h95%Resets in 41m");
expect(work).toHaveTextContent("Week32%");
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired");
expect(panel).toHaveTextContent("Could not read the quota");
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
expect(chips).toHaveTextContent("Antigravity50% used");
expect(cursor).toHaveTextContent("c@example.compro");
expect(cursor).toHaveTextContent("Month40%");
expect(chips).toHaveTextContent("Cursor40% used");
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
await user.unhover(chips);
await user.hover(panel);
@@ -0,0 +1,143 @@
/**
* @file WorkspaceTree.owners.test.tsx
* @description Server mode, the sidebar workspace list for an analyst: every
* user's workspaces, grouped under their owner (yours first, no owner last),
* groups folding and remembered; another user's workspace is read only — no
* menu, no new session, and a session opens its analysis page, never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../lib/types";
const ME = { id: 2, username: "ana", role: "analyst" as const };
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
({
id,
title,
cwd: `/w/${id}`,
kind: "adopted",
tool: "claude",
status: "idle",
liveness: "idle",
pipeline_nodes: [],
owner_id: owner,
owner_name: name,
}) as unknown as Lane;
const lanes = [
lane(1, 3, "bob", "Bob app"),
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
];
vi.mock("../AuthGate", () => ({
useAuth: () => ({ state: { mode: "server", user: ME } }),
usePermissions: () => ({
isAdmin: false,
seesAll: true,
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
}),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
sessions: {
list: vi.fn(async () => ({
sessions: [
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
] as Session[],
total: 1,
limit: 6,
offset: 0,
})),
},
run: { list: vi.fn(async () => ({ items: [] })) },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
},
};
});
import { WorkspaceTree } from "../WorkspaceTree";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderTree() {
return render(
<MemoryRouter>
<Routes>
<Route
path="*"
element={
<>
<WorkspaceTree />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => localStorage.clear());
describe("WorkspaceTree for an analyst", () => {
it("groups every workspace under its owner: yours first, no owner last", async () => {
renderTree();
await screen.findByTestId("workspace-row-1");
const order = screen
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
.map((el) => el.dataset.testid);
expect(order).toEqual([
"workspace-owner-2",
"workspace-row-3",
"workspace-owner-1",
"workspace-row-4",
"workspace-owner-3",
"workspace-row-1",
"workspace-owner-none",
"workspace-row-2",
]);
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
});
it("folds a group and remembers it", async () => {
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-owner-3"));
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
"3": true,
});
});
it("keeps another user's workspace read only", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// Yours keeps its controls; theirs has none.
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
// Picking it unfolds it instead of opening a workspace home.
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
const list = await screen.findByTestId("workspace-sessions-1");
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
await user.click(within(list).getByTestId("workspace-session-s-1"));
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
});
});
@@ -178,6 +178,10 @@ describe("WorkspaceTree", () => {
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
await user.click(screen.getByTestId("workspace-sessions-less-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
});
it("renames a session on double-click + Enter, and archives one from its row", async () => {
@@ -307,6 +311,23 @@ describe("WorkspaceTree", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("lists live sessions above ended ones, even an ended one active more recently", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [session("s-old", "Old one"), session("s-live", "Live one")],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await waitFor(() =>
expect(screen.getByTestId("workspace-session-s-live")).toHaveAttribute("data-live", "true")
);
const ids = screen.getAllByTestId(/^workspace-session-s-/).map((el) => el.dataset.testid);
expect(ids).toEqual(["workspace-session-s-live", "workspace-session-s-old"]);
});
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
vi.mocked(api.run.list).mockImplementation(
async () =>
@@ -5,7 +5,11 @@
* the message stream via MessageList. Combines a WebSocket subscription, a
* visibility-gated polling fallback, and a manual refresh button so the view
* stays caught up even when hooks miss frames or the user is mid-text-only
* turn (no PreToolUse fires until Stop).
* turn (no PreToolUse fires until Stop). The view sticks to its end while
* the reader is there: anything that makes it taller — a new line, the live
* step, an image loading, the pane shown again after being hidden by CSS
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -76,9 +80,25 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
interface ConversationViewProps {
sessionId: string;
initialTranscriptId?: string | null;
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
}
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
export function ConversationView({
sessionId,
initialTranscriptId,
fill = false,
onOpenFile,
live = false,
follow = 0,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [total, setTotal] = useState(0);
@@ -91,11 +111,14 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
// Scrolled up from the end: a chat (`fill`) offers the way back down.
const [awayFromEnd, setAwayFromEnd] = useState(false);
// Track JSONL line numbers for incremental requests and history loading
const lastLineRef = useRef(0);
const firstLineRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const fetchingRef = useRef(false);
// When a fetch is in flight and a new trigger arrives (WS event, poll,
@@ -370,6 +393,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Detect if at bottom
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
isAtBottomRef.current = atBottom;
setAwayFromEnd(!atBottom);
// Hide "new messages" indicator when scrolled to bottom
if (atBottom) {
@@ -382,6 +406,30 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}
}, [hasMore, loadingHistory, loadHistory]);
// Stick to the end while the reader is there. Scroll events say whether
// they are; a size change — of the content, or of the box itself when a
// hidden pane is shown — never fires one, so it is watched here.
useEffect(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
});
observer.observe(container);
observer.observe(content);
return () => observer.disconnect();
}, []);
// The reader sent a message: show it, and what follows, as it lands.
useEffect(() => {
if (!follow) return;
isAtBottomRef.current = true;
setAwayFromEnd(false);
setShowNewMsg(false);
scrollToBottom();
}, [follow, scrollToBottom]);
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
@@ -390,10 +438,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
{!loading && (
{!loading && !fill && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && (
<div className="relative">
@@ -440,42 +488,66 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
<div ref={contentRef}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">
↑ {t("conversation.scrollForOlder")}
</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} />
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
)}
</div>
</div>
{/* Claude Desktop's round "back to the end" button */}
{fill && awayFromEnd && (
<button
type="button"
data-testid="conversation-to-end"
onClick={() => {
scrollToBottom();
setShowNewMsg(false);
}}
aria-label={t("conversation.newMessages")}
title={t("conversation.newMessages")}
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
>
<ArrowDown className="h-4 w-4" />
{showNewMsg && (
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
)}
</button>
)}
{/* New messages indicator */}
{showNewMsg && (
{showNewMsg && !fill && (
<button
onClick={() => {
scrollToBottom();
@@ -4,7 +4,9 @@
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs.
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
* through `OpenFileContext` when the page provides one; `linkify` does the
* same links for plain text.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
@@ -244,6 +246,71 @@ function parseBlocks(src: string): Block[] {
return blocks;
}
/** Opens a file a message names; provided by a view that can show one. */
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
const LINK_CLASS =
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
// A link starts a word: "and/or" or "a/b/c" stays text.
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
function UrlLink({ url }: { url: string }) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{url}
</a>
);
}
/** A path as a button that opens it, or as it was when nothing can. */
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
const open = React.useContext(OpenFileContext);
if (!open) return <>{children}</>;
return (
<button
type="button"
title={path}
onClick={() => open(path)}
className={`${LINK_CLASS} text-left`}
>
{children}
</button>
);
}
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
export function linkify(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let buf = "";
for (let i = 0; i < text.length;) {
const rest = text.slice(i);
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
const hit = url || path;
if (!hit) {
buf += text[i++];
continue;
}
if (buf) out.push(buf);
buf = "";
out.push(
url ? (
<UrlLink key={i} url={url} />
) : (
<PathLink key={i} path={hit}>
{hit}
</PathLink>
)
);
i += hit.length;
}
if (buf) out.push(buf);
return out;
}
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
@@ -267,11 +334,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Inline code: `...`
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
push(
const code = (
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
{codeM[1]}
</code>
);
push(
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
)
);
i += codeM[0].length;
continue;
}
@@ -330,22 +404,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
}
// Auto-link
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
const urlM = rest.match(URL_RE);
if (urlM) {
push(
<a
href={urlM[0]}
target="_blank"
rel="noopener noreferrer"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
>
{urlM[0]}
</a>
);
push(<UrlLink url={urlM[0]} />);
i += urlM[0].length;
continue;
}
// An absolute path at a word start
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
if (pathM) {
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
i += pathM[0].length;
continue;
}
buf += text[i]!;
i++;
}
@@ -381,6 +454,8 @@ interface MarkdownContentProps {
text: string;
/** Tighter spacing for nested contexts (list items, quotes). */
dense?: boolean;
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
prose?: boolean;
}
const HEADING_STYLES = [
@@ -392,12 +467,16 @@ const HEADING_STYLES = [
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
];
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
const blocks = parseBlocks(text);
const gap = dense ? "space-y-1.5" : "space-y-2.5";
// A paragraph or list item takes the root's size and colour in prose.
const bodyText = prose ? "" : "text-sm text-fg-secondary";
return (
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
<div
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
>
{blocks.map((b, idx) => {
switch (b.kind) {
case "code":
@@ -420,7 +499,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
@@ -433,7 +512,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
@@ -503,7 +582,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
case "para":
return (
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
{renderInline(b.text, `p${idx}`)}
</p>
);
@@ -4,7 +4,19 @@
* transcript: alternating user / assistant rows with collapsible thinking
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page.
* the Conversation tab on the Session detail page. An image the prompt box
* attached reaches the transcript as its saved path; a user turn shows the
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
* a System row; URLs and absolute paths in a turn are links (a path opens
* through `onOpenFile`). Each assistant turn that changed
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`). A thinking block that carries text is
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
* chat (`live`) ends, until its turn is over, with what the model is doing
* now — the call still running, else "Thinking…" — and for how long.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -53,7 +65,8 @@
*
* ----------------------------------------------------------------------------- */
import { memo, useState, useMemo } from "react";
import { memo, useEffect, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
import {
@@ -69,6 +82,8 @@ import {
Pencil,
Workflow,
Cog,
FileText,
Loader2,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -111,14 +126,18 @@ const SENDER_STYLES: Record<
headerText: "text-status-warning",
},
};
import { ToolCallBlock } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent";
import { fmt, formatModelName } from "../../lib/format";
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
interface MessageListProps {
messages: TranscriptMessage[];
loading: boolean;
/** Opens a file named in the "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -135,6 +154,16 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
return map;
}
/** The whole prompt a message was answered from: `input_tokens` is only its
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
}
const usageTitle = (u: NonNullable<TranscriptMessage["usage"]>) =>
`input ${fmt(u.input_tokens || 0)} · cache read ${fmt(u.cache_read_input_tokens || 0)} · cache write ${fmt(u.cache_creation_input_tokens || 0)} · output ${fmt(u.output_tokens || 0)}`;
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
function isSkillContent(text: string): boolean {
return text.startsWith("Base directory for this skill:");
@@ -264,6 +293,60 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
}
}
/** A text block of any sender: a task notification or skill body collapsed,
* TUI markup as segments, anything else as markdown. */
function renderTextBlock(text: string, key: number): React.ReactNode {
// Detect task notifications, collapsed by default
if (isTaskNotification(text)) {
return (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />}
title={i18n.t("sessions:conversation.taskNotification")}
borderClass="border-syntax-property/20"
bgClass="bg-syntax-property/5"
textClass="text-syntax-property"
/>
);
}
// Detect skill content, collapsed by default
if (isSkillContent(text)) {
const pathMatch = text.match(/^Base directory for this skill:\s*(\S+)/);
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
return (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
title={skillPath}
borderClass="border-syntax-builtin/20"
bgClass="bg-syntax-builtin/5"
textClass="text-syntax-builtin"
/>
);
}
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
// can appear inline (sometimes interleaved with prose). Parse the text
// into segments and render each with the appropriate visual treatment.
if (hasTuiTags(text)) {
const segments = parseTuiSegments(text);
return (
<div key={key} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={key} className="min-w-0">
<MarkdownContent text={stripAnsi(text)} />
</div>
);
}
/** Generic collapsible content block */
function CollapsibleBlock({
text,
@@ -326,10 +409,151 @@ export function messageKeys(messages: TranscriptMessage[]): string[] {
});
}
export function MessageList({ messages, loading }: MessageListProps) {
/** One thing on screen, in the Claude Desktop layout: a human message as a
* bubble, the assistant's prose bare, and everything it did between two
* pieces of prose — tool calls and thinking, across messages (Claude Code
* writes each as its own line) — as ONE collapsed line; any other sender
* keeps its attributed row. */
type Item =
| { kind: "event"; key: string; msg: TranscriptMessage }
| { kind: "row"; key: string; msg: TranscriptMessage }
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
};
messages.forEach((msg, idx) => {
const key = keys[idx]!;
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
// Purely tool_result: shown inside its call's block.
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") {
endTurn(key);
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") {
// Claude Code's line for the image a prompt attached: the image joins that prompt.
const paths = sender === "system" ? imageSourcePaths(msg) : null;
const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
if (paths && prompt?.kind === "user") {
prompt.msg = {
...prompt.msg,
content: [
...prompt.msg.content.map((c) =>
c.type === "text" && c.text
? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
: c
),
{ type: "text", text: paths.join("\n") },
],
};
return;
}
return items.push({ kind: "row", key, msg });
}
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (block.type === "tool_use") {
if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
}
});
});
endTurn(keys[keys.length - 1] ?? "end");
return items;
}
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
function EditedFiles({
blocks,
toolResultMap,
onOpenFile,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
for (const b of blocks) {
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
if (files.size === 0) return null;
let added = 0;
let removed = 0;
let counted = false;
files.forEach((f) => {
added += f.added;
removed += f.removed;
counted ||= f.counted;
});
const counts = (a: number, r: number, show = counted) =>
show && (
<span className="flex-shrink-0 font-mono text-xs">
<span className="text-status-success">+{a}</span>{" "}
<span className="text-status-danger">−{r}</span>
</span>
);
return (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
{counts(added, removed)}
</div>
{[...files].map(([p, f]) => (
<button
key={p}
type="button"
title={p}
disabled={!onOpenFile}
onClick={() => onOpenFile?.(p)}
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
>
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{p.split("/").pop()}</span>
{counts(f.added, f.removed, f.counted)}
{onOpenFile && <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />}
</button>
))}
</div>
);
}
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
);
const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
if (loading) {
return (
@@ -344,23 +568,445 @@ export function MessageList({ messages, loading }: MessageListProps) {
}
return (
<div className="space-y-3">
{messages.map((msg, idx) => {
// Session lifecycle markers (e.g. /rename) render as a centered chip,
// not as a user/assistant row.
if (msg.type === "session_event") {
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
}
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
return null;
}
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
})}
<OpenFileContext.Provider value={onOpenFile}>
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
return (
<SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return (
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
);
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
/>
);
}
})}
{pending && <RunningStep block={pending.block} since={pending.since} />}
</div>
</OpenFileContext.Provider>
);
}
// No line for this long and no call running: the turn died with no end
// written (a crash, a kill) — stop saying it works.
const IDLE_AFTER_MS = 15 * 60_000;
/** What the model is doing while its turn is not over: the call still
* waiting for its result (`block`), else thinking (`block: null`); `since`
* is that line's time. Null once the turn ended. */
export function workingOn(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
// A rename marker or a harness line says nothing about the turn.
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
const since = msg.timestamp ?? null;
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
if (msg.type === "assistant") {
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
if (call?.id && !results.has(call.id)) return { block: call, since };
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
}
// A prompt or a tool result: the model answers it next — unless the user stopped it.
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
if (text.startsWith("[Request interrupted")) return null;
return fresh ? { block: null, since } : null;
}
return null;
}
/** Claude Desktop's live line: the step running now and for how long. */
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const input = block?.input as Record<string, unknown> | undefined;
const title = !block
? t("conversation.working")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
return (
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
<span className="min-w-0 truncate">{title}</span>
{since && (
<span className="flex-shrink-0 font-mono text-xs">
{formatMs(now - parseDate(since).getTime())}
</span>
)}
</div>
);
}
const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached image: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
const DROP_IMAGE =
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
/** A user turn's text without its dropped-image paths (and Claude Code's
* `[Image #N]` placeholders for them), and those images' names. */
export function splitDropImages(text: string): { text: string; images: string[] } {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
return "";
});
return {
text: images.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
images,
};
}
/** The drop paths of a meta message made only of `[Image: source: <drop>]` lines. */
function imageSourcePaths(msg: TranscriptMessage): string[] | null {
const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : []));
const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? "");
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
}
function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
const src = `/api/run/drop/${encodeURIComponent(name)}`;
// Drops are pruned after a day: the name is all that is left to show.
if (gone)
return (
<span className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{name.replace(/^[0-9a-f]{16}-?/, "") || name}</span>
</span>
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="block overflow-hidden rounded-xl border border-border bg-surface-2 hover:border-accent/50"
>
<img
src={src}
alt={name}
loading="lazy"
onError={() => setGone(true)}
className="max-h-48 max-w-64 object-contain"
/>
</button>
{open && <ImageLightbox src={src} alt={name} onClose={() => setOpen(false)} />}
</>
);
}
/** The human's turn: a bubble on the right, its time on hover. A long one (a
* paste, a compaction summary) shows its first lines until opened. */
const LONG_BUBBLE = 800;
const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage }) {
const { t } = useTranslation("sessions");
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
const [open, setOpen] = useState(false);
const blocks = useMemo(
() =>
msg.content.map((block) =>
block.type === "text" && block.text ? splitDropImages(block.text) : null
),
[msg.content]
);
const images = blocks.flatMap((b) => b?.images ?? []);
const hasText = blocks.some((b) => b?.text);
return (
<div
data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
{images.length > 0 && (
<div
data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2"
>
{images.map((name) => (
<DropImage key={name} name={name} />
))}
</div>
)}
{hasText && (
<div
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{blocks.map((block, b) => {
const text = block?.text;
if (!text) return null;
// Plain typing reads as typed; anything structured keeps its rendering.
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{linkify(stripAnsi(text).trim())}
</p>
) : (
renderTextBlock(text, b)
);
})}
</div>
)}
{long && (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="text-xs text-fg-muted hover:text-fg-secondary"
>
{open ? t("conversation.showLess") : t("conversation.showMore")}
</button>
)}
{msg.timestamp && (
<span className={`${meta} opacity-0 transition-opacity group-hover:opacity-100`}>
{formatLocalTime(msg.timestamp)}
</span>
)}
</div>
);
});
/** The assistant's prose, bare on the page; model, tokens and time on hover. */
const AssistantText = memo(function AssistantText({
msg,
text,
}: {
msg: TranscriptMessage;
text: string;
}) {
return (
<div
data-testid="chat-assistant"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
className="group min-w-0"
>
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
renderTextBlock(text, 0)
) : (
<MarkdownContent text={stripAnsi(text)} prose />
)}
<div
className={`${meta} mt-1 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100`}
>
{msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && (
<span title={usageTitle(msg.usage)}>
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
</span>
)}
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
</div>
</div>
);
});
/** A muted line that opens to show what it stands for. */
function Disclosure({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 text-sm text-fg-muted transition-colors hover:text-fg-secondary"
>
{label}
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
</button>
{open && <div className="mt-2 space-y-2">{children}</div>}
</div>
);
}
/** The kind of work a tool call is, for the group's label (Claude Code and
* Codex names); anything else counts as a tool. */
const WORK_KIND: Record<string, string> = {
bash: "commands",
shell: "commands",
exec_command: "commands",
powershell: "commands",
edit: "edits",
multiedit: "edits",
write: "edits",
notebookedit: "edits",
apply_patch: "edits",
read: "reads",
grep: "searches",
glob: "searches",
ls: "searches",
websearch: "web",
webfetch: "web",
task: "agents",
agent: "agents",
};
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
export function workLabel(blocks: TranscriptContent[]): string {
const counts = new Map<string, number>();
for (const b of blocks) {
if (b.type !== "tool_use") continue;
const kind = WORK_KIND[(b.name ?? "").toLowerCase()] ?? "tools";
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
if (!counts.size) return i18n.t("sessions:conversation.thinking");
// Claude Desktop's order: commands, edits, reads, … and other tools last.
const text = [...counts]
.sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b))
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
.join(", ");
return text.charAt(0).toUpperCase() + text.slice(1);
}
/** One step inside an opened group, as Claude Desktop lists them: its own
* description (Claude Code's Bash `description`) or the tool and target as
* the title, the call itself under it in a capped scroll box, the result one
* click further. Thinking is a step too, closed. */
function WorkStep({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const thinking = block.type === "thinking";
const [open, setOpen] = useState(!thinking);
const [showResult, setShowResult] = useState(false);
const input = block.input as Record<string, unknown> | undefined;
const title = thinking
? t("conversation.thinking")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
return (
<div className="px-3 py-2">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={`flex w-full min-w-0 items-center gap-1 text-left text-sm transition-colors hover:text-fg-secondary ${
result?.is_error ? "text-status-danger" : "text-fg-muted"
}`}
>
<span className="truncate">{title}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
{open && (
<div className="mt-2 space-y-2">
{thinking ? (
<div className="max-h-60 overflow-auto text-fg-muted">
<MarkdownContent text={block.text ?? ""} dense />
</div>
) : (
<>
<div className="max-h-60 overflow-auto">
{/* The description is the title already. */}
{renderInput(
input ? { ...block, input: { ...input, description: undefined } } : block
)}
</div>
{result && (
<button
type="button"
aria-expanded={showResult}
onClick={() => setShowResult(!showResult)}
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-fg-secondary"
>
{result.is_error ? t("conversation.tool.error") : t("conversation.tool.output")}
<ChevronRight
className={`h-3 w-3 transition-transform ${showResult ? "rotate-90" : ""}`}
/>
</button>
)}
{result && showResult && (
<div className="max-h-60 overflow-auto">
{renderResult(result, block.name ?? "")}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/** What the assistant did between two replies as one line, opening onto
* one framed list of its steps. */
const WorkGroup = memo(function WorkGroup({
blocks,
toolResultMap,
}: {
blocks: TranscriptContent[];
toolResultMap: ReturnType<typeof buildToolResultMap>;
}) {
return (
<div data-testid="chat-tools">
<Disclosure label={workLabel(blocks)}>
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{blocks.map((block, i) => (
<WorkStep
key={block.id ?? i}
block={block}
result={block.id ? (toolResultMap.get(block.id) ?? null) : null}
/>
))}
</div>
</Disclosure>
</div>
);
});
/** One user/assistant row. Memoized: a live tail append re-renders only the
* new rows; its thinking-block expansion is its own state, kept by key. */
const MessageRow = memo(function MessageRow({
@@ -404,8 +1050,11 @@ const MessageRow = memo(function MessageRow({
</span>
)}
{msg.usage && (
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span
title={usageTitle(msg.usage)}
className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"
>
<span className="text-status-success/70">↓ {fmt(promptTokens(msg.usage))}</span>
<span className="text-fg-muted">·</span>
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
</span>
@@ -419,59 +1068,7 @@ const MessageRow = memo(function MessageRow({
{/* Content blocks */}
{msg.content.map((block, bIdx) => {
if (block.type === "text" && block.text) {
// Detect task notifications, collapsed by default
if (isTaskNotification(block.text)) {
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
}
title={t("conversation.taskNotification")}
borderClass="border-syntax-property/20"
bgClass="bg-syntax-property/5"
textClass="text-syntax-property"
/>
);
}
// Detect skill content, collapsed by default
if (isSkillContent(block.text)) {
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
title={skillPath}
borderClass="border-syntax-builtin/20"
bgClass="bg-syntax-builtin/5"
textClass="text-syntax-builtin"
/>
);
}
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
// can appear inline (sometimes interleaved with prose). Parse the text
// into segments and render each with the appropriate visual treatment.
if (hasTuiTags(block.text)) {
const segments = parseTuiSegments(block.text);
return (
<div key={bIdx} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={bIdx} className="min-w-0">
<MarkdownContent text={stripAnsi(block.text)} />
</div>
);
}
if (block.type === "text" && block.text) return renderTextBlock(block.text, bIdx);
if (block.type === "thinking" && block.text) {
const thinkKey = bIdx;
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
}
/** Build a one-line summary of the tool call to show in the collapsed header. */
function buildSummary(toolUse: TranscriptContent): string | null {
export function buildSummary(toolUse: TranscriptContent): string | null {
const input = toolUse.input;
if (!input || typeof input !== "object" || "_truncated" in input) return null;
const obj = input as Record<string, unknown>;
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
/** Render the input pane with tool-aware formatting. */
function renderInput(toolUse: TranscriptContent) {
export function renderInput(toolUse: TranscriptContent) {
const input = toolUse.input;
if (!input) return null;
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
}
/** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) {
export function renderResult(toolResult: TranscriptContent, toolName: string) {
const text = toolResult.output ?? "";
if (text.length === 0)
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
@@ -0,0 +1,93 @@
/**
* @file ConversationView.follow.test.tsx
* @description A chat sticks to its end: when it grows or is shown again
* after being hidden (a size change, which fires no scroll event) it scrolls
* to the newest while the reader is at the end, leaves a reader who scrolled
* up alone, and a send (`follow`) brings it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let resize: (() => void) | null = null;
const original = globalThis.ResizeObserver;
beforeEach(() => {
resize = null;
// @ts-expect-error test stub
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
resize = cb;
}
observe() {}
disconnect() {}
};
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
vi.restoreAllMocks();
});
/** The scroll box, with the layout jsdom does not compute. */
function box(height: number) {
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
return el;
}
describe("ConversationView sticks to its end", () => {
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
const view = render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const el = box(1000);
// Shown again after display:none, or taller: only a resize says so.
act(() => resize?.());
expect(el.scrollTop).toBe(1000);
// The reader scrolls up: growing no longer pulls them down.
el.scrollTop = 100;
fireEvent.scroll(el);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
act(() => resize?.());
expect(el.scrollTop).toBe(100);
// They send a message: back to the end, and it sticks again.
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
expect(el.scrollTop).toBe(1400);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
act(() => resize?.());
expect(el.scrollTop).toBe(1600);
});
});
@@ -0,0 +1,70 @@
/**
* @file MessageList.edits.test.tsx
* @description Each assistant turn that changed files ends with Claude
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, and a row click handed
* to the caller.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-09T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id, name, input: { file_path } }],
}) as TranscriptMessage;
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
}) as TranscriptMessage;
describe("the Edited N files card", () => {
it("sums each file's lines over the turn and opens a file on click", () => {
const onOpenFile = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
call("4", "Edit", "/w/c.ts"),
result("4", undefined, true),
user("thanks"),
call("5", "Read", "/w/a.ts"),
result("5"),
]}
/>
);
const cards = screen.getAllByTestId("edited-files");
expect(cards).toHaveLength(1); // the second turn only read
const card = cards[0]!;
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
});
});
@@ -0,0 +1,113 @@
/**
* @file MessageList.images.test.tsx
* @description An image the prompt box attached reaches the transcript as its
* saved drop path; the user's turn shows the image (served by
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
* other path as typed. Claude Code turns a pasted drop path into an image
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
* URLs and absolute paths in a turn are links.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, splitDropImages } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const SHOT = "366d0a87d12176a2-image.png";
describe("dropped images in a user turn", () => {
it("splitDropImages takes drop-dir image paths out of the text", () => {
expect(
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
]);
// The drop dir since the data-dir move.
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
text: "fix this",
images: [SHOT],
});
// Not a drop, or not an image: left as typed.
for (const text of [
"/home/me/shot.png",
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
]) {
expect(splitDropImages(text)).toEqual({ text, images: [] });
}
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("look at this");
expect(bubble).not.toHaveTextContent("agenthub-drops");
const img = screen.getByRole("img", { name: SHOT });
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
fireEvent.click(img);
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
});
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
},
{
type: "user",
sender: "system",
timestamp: "2026-10-09T09:43:11.000Z",
content: [
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
expect(screen.queryByText("System")).toBeNull();
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
expect(bubble).not.toHaveTextContent("[Image #1]");
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
`/api/run/drop/${SHOT}`
);
});
it("makes URLs and absolute paths clickable", () => {
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T09:43:00.000Z",
content: [
{
type: "text",
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
},
],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
expect(link.getAttribute("target")).toBe("_blank");
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
});
});
@@ -1,13 +1,15 @@
/**
* @file MessageList.sender.test.tsx
* @description Verifies the transcript renders each message under its TRUE
* sender label — User / Assistant / Main agent / System — instead of labeling
* every `type:"user"` line "User" (reported transcript mis-attribution).
* @description Verifies the transcript renders each message by its TRUE
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MessageList } from "../MessageList";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -36,8 +38,8 @@ describe("MessageList — sender attribution", () => {
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
expect(screen.getByText("System")).toBeInTheDocument();
expect(screen.getByText("Main agent")).toBeInTheDocument();
});
@@ -48,7 +50,158 @@ describe("MessageList — sender attribution", () => {
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
});
it("folds consecutive tool calls — one per transcript line — into one line", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
render(<MessageList messages={messages} loading={false} />);
const group = screen.getAllByTestId("chat-tools");
expect(group).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
expect(group[0]).toHaveTextContent("echo b");
});
it("names a mixed run by what it did, like Claude Desktop", () => {
const use = (name: string) => ({ type: "tool_use" as const, name });
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
"Ran 2 commands, edited a file, read a file"
);
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
// Desktop's fixed order, whatever ran first: other tools always last.
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
"Ran 1 command, read a file, used a tool"
);
});
it("reads a thinking block's text as narration, between two groups", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const say = (text: string) =>
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
render(
<MessageList
loading={false}
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
/>
);
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
expect(screen.getByText("Found an issue.")).toBeTruthy();
});
it("shows the whole prompt as ↓, not just its uncached tail", () => {
render(
<MessageList
loading={false}
messages={[
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: 2,
output_tokens: 40,
cache_read_input_tokens: 120000,
cache_creation_input_tokens: 3000,
},
}),
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
});
it("ends a live chat with the step still running", () => {
const messages = [
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [
{
type: "tool_use",
id: "t1",
name: "Bash",
input: { command: "npm test", description: "Run the full client suite" },
},
],
}),
];
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite");
expect(row).toHaveTextContent(/1m \ds/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
loading={false}
live
messages={[
...messages,
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
]}
/>
);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
it("knows what a live turn is doing until it ends", () => {
const now = Date.parse("2026-06-26T08:20:00.000Z");
const at = "2026-06-26T08:19:00.000Z";
const prompt = msg({ type: "user", sender: "user", timestamp: at });
const call = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
});
const result = msg({
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id: "t1", output: "ok" }],
});
const reply = (stop_reason?: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason,
content: [{ type: "text", text: "ok" }],
});
const results = (m: ReturnType<typeof msg>[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
const stopped = msg({
type: "user",
sender: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
});
+29 -5
View File
@@ -10,7 +10,8 @@
* (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
* which a click into the field opens too.
* The worktree lane returned is `status: "provisioning"`:
* the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the
@@ -29,6 +30,7 @@ import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { readDefaultTool } from "../../lib/sessionDefaults";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -111,7 +113,14 @@ export function AddLaneModal({
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
// The account's own pick (first-run setup), while it is installed and on.
const ownTool = readDefaultTool();
const defaultTool =
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
? ownTool
: null) ??
aiTools.defaultId ??
"claude";
const tool = pickedTool ?? defaultTool;
// Installed + enabled, the default first.
const toolChoices = aiTools.tools
@@ -319,6 +328,7 @@ export function AddLaneModal({
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const browseRef = useRef<HTMLButtonElement>(null);
useDialog(open, panelRef, handleCancel, cancelRef);
// A summary on screen means the lane already exists and a step failed, so
@@ -355,6 +365,7 @@ export function AddLaneModal({
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-tour="add-lane"
>
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
@@ -449,8 +460,15 @@ export function AddLaneModal({
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<div className="flex gap-1.5" data-tour="add-lane-folder">
{/* A click into the path opens the picker, as Browse does (the
OS's own where it can be); typing still works from the keyboard. */}
<div
className="min-w-0 flex-1"
onClick={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
}}
>
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
@@ -458,7 +476,12 @@ export function AddLaneModal({
suggestions={cwdSuggestions}
/>
</div>
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
<PathPickButton
start={sourceRepo}
onPick={setSourceRepo}
showLabel
buttonRef={browseRef}
/>
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
@@ -695,6 +718,7 @@ export function AddLaneModal({
type="button"
onClick={done ? handleCancel : submit}
disabled={busy || disabled}
data-tour="add-lane-create"
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
+204 -21
View File
@@ -13,23 +13,39 @@
* text), its edit in a stronger blue — the same scheme as the read-only diff in
* LaneDiffView. Syntax colours come from the dashboard's own per-line
* tokenizer (`lib/highlight.ts`), like every other code view here.
* Given `onBlock`, each changed block also gets the read-only diff's Undo /
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
* panel turns it off while the editor holds unsaved text, because git acts on
* the file on disk.
* Given `onBlock`, each changed block also gets the read-only diff's bar under
* its last line — Undo (asks twice) and the block's commit checkbox
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
* the editor holds unsaved text, because git acts on the file on disk.
* Given `blame`, a gutter left of the line numbers names each line's last
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip at the end of its first line (inline, so it covers no code),
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
import {
Compartment,
EditorSelection,
EditorState,
RangeSetBuilder,
StateEffect,
StateField,
Text,
} from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
drawSelection,
gutter,
GutterMarker,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
@@ -41,6 +57,9 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirro
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
import { chunksOf, splitDiff } from "./splitDiff";
import { highlight, tokenClass } from "../../lib/highlight";
import type { LaneBlame } from "../../lib/types";
type Blame = Extract<LaneBlame, { available: true }>;
/**
* True when a letter or digit of a line (`text`, starting at document offset
@@ -76,7 +95,7 @@ function decorate(view: EditorView, lang: string): DecorationSet {
const doc = view.state.doc;
let last = 0;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
for (let pos = from; pos <= to;) {
const line = doc.lineAt(pos);
pos = line.to + 1;
if (line.number <= last) continue;
@@ -151,7 +170,52 @@ const diffColours = (lang: string) =>
/** A changed block by line (1-based; a count of 0 is a pure insertion or
* removal): what the panel matches against git's own change blocks. */
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
export type BlockAction = "keep" | "undo";
export type BlockAction = "include" | "undo";
/** One Annotate cell: the line's commit date and author, its subject on hover. */
class BlameMarker extends GutterMarker {
constructor(
readonly text: string,
readonly title: string
) {
super();
}
override eq(other: BlameMarker) {
return other.text === this.text && other.title === this.title;
}
override toDOM() {
const el = document.createElement("span");
el.textContent = this.text;
el.title = this.title;
return el;
}
}
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
* lines are matched by number, so unsaved edits above a line shift its
* annotation until the file is saved and re-annotated. */
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
return gutter({
class: "cm-ah-blame",
lineMarker(view, line) {
const sha = shaAt(view.state, line.from);
if (sha === undefined) return null;
const c = sha ? blame.commits[sha] : undefined;
return c
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
: new BlameMarker(notCommitted, notCommitted);
},
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
domEventHandlers: {
mousedown(view, line) {
const sha = shaAt(view.state, line.from);
if (sha) onClick(sha);
return !!sha;
},
},
});
}
/** A chunk side's first line and line count. `to` is the start of the line
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
@@ -220,6 +284,44 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
class ChatChip extends WidgetType {
constructor(
readonly start: number,
readonly end: number,
readonly build: (start: number, end: number) => HTMLElement
) {
super();
}
eq(o: ChatChip) {
return this.start === o.start && this.end === o.end;
}
toDOM() {
return this.build(this.start, this.end);
}
}
/** Add to Chat inline at the end of a non-empty selection's first line —
* the empty space right of the code, so it covers no line. `build` makes its
* DOM from the selection's first and last 1-based line. A selection ending
* at a line's start (a whole-line drag) does not take that line. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
if (!tr.docChanged && !tr.selection) return deco;
const { from, to, empty } = tr.state.selection.main;
if (empty) return Decoration.none;
const doc = tr.state.doc;
const first = doc.lineAt(from);
const last = doc.lineAt(to);
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
const widget = new ChatChip(first.number, end, build);
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
},
provide: (f) => EditorView.decorations.from(f),
});
}
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
const UNDO =
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
@@ -278,6 +380,13 @@ const theme = EditorView.theme({
background: "rgb(var(--surface-2))",
},
".cm-chunkButtons": { zIndex: "1" },
".cm-ah-blame .cm-gutterElement": {
color: "rgb(var(--fg-muted))",
cursor: "pointer",
fontSize: "10px",
paddingRight: "6px",
whiteSpace: "nowrap",
},
});
/** The Revert button on a changed block (unified); @codemirror/merge wires
@@ -304,6 +413,11 @@ export default function DiffEditor({
onChange,
onSave,
onBlock,
blockIncluded,
blame,
onBlameClick,
navRef,
onAddToChat,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
@@ -318,14 +432,24 @@ export default function DiffEditor({
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
/** Keep / Undo one changed block in git; absent = no bars. */
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
onBlock?: (action: BlockAction, block: EditorBlock) => void;
/** Whether a block is ticked for the commit (its checkbox's state). */
blockIncluded?: (block: EditorBlock) => boolean;
/** Annotate: each line's last commit; absent = no gutter. */
blame?: Blame;
onBlameClick?: (sha: string) => void;
/** Set to "go to the next (1) / previous (-1) changed block". */
navRef?: { current: ((dir: 1 | -1) => void) | null };
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
onAddToChat?: (start: number, end: number) => void;
}) {
const { t } = useTranslation("lanes");
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave, onBlock, t });
cb.current = { onChange, onSave, onBlock, t };
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
const blameSlot = useRef(new Compartment()).current;
/** The diff view's review bar, as DOM: pinned to the visible right edge,
* zero height so it never pushes lines (or a split view's alignment). */
@@ -349,11 +473,13 @@ export default function DiffEditor({
return b;
};
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
const keep = chip(
tr("changes.stageHunkShort"),
tr("changes.stageHunk"),
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
);
// The block's commit checkbox, a real one so it reads like the diff's.
const include = bar.appendChild(document.createElement("input"));
include.type = "checkbox";
include.checked = cb.current.blockIncluded?.(block) ?? true;
include.title = tr("changes.includeBlock");
include.setAttribute("aria-label", tr("changes.includeBlock"));
include.className = "h-3 w-3 cursor-pointer";
// Two clicks, like every undo git cannot give back; disarms after 3 s.
let timer = 0;
const arm = (on: boolean) => {
@@ -374,13 +500,17 @@ export default function DiffEditor({
arm(true);
timer = window.setTimeout(() => arm(false), 3000);
};
keep.onclick = () => cb.current.onBlock?.("keep", block);
include.onchange = () => cb.current.onBlock?.("include", block);
return wrap;
};
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme];
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
// would rewrite every line ending.
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
const editing = [
...common,
highlightActiveLineGutter(),
@@ -402,9 +532,26 @@ export default function DiffEditor({
]),
EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
}),
blockBars(Text.of(original.split("\n")), buildBar),
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
blameSlot.of([]),
...(onAddToChat
? [
chatChip((start, end) => {
const b = document.createElement("button");
b.type = "button";
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
return b;
}),
]
: []),
];
let v: EditorView;
let destroy: () => void;
@@ -446,11 +593,47 @@ export default function DiffEditor({
}
v.focus();
view.current = v;
return destroy;
if (navRef) {
// The B side's chunks, unified or split: the cursor goes to the start
// of the next / previous one and it scrolls into view.
navRef.current = (dir) => {
const ed = view.current;
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
if (!ed || !chunks.length) return;
const at = ed.state.selection.main.head;
const next =
dir === 1
? chunks.find((c) => c.fromB > at)
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
if (!next) return;
ed.dispatch({
selection: EditorSelection.cursor(next.fromB),
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
});
ed.focus();
};
}
return () => {
if (navRef) navRef.current = null;
destroy();
};
// One mount per view: the parent keys this component by file and shape.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Annotate on / off without remounting the view.
useEffect(() => {
view.current?.dispatch({
effects: blameSlot.reconfigure(
blame
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
cb.current.onBlameClick?.(sha)
)
: []
),
});
}, [blame, blameSlot]);
const bars = !!onBlock;
useEffect(() => {
view.current?.dispatch({ effects: barsOn.of(bars) });
@@ -459,7 +642,7 @@ export default function DiffEditor({
// A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => {
const v = view.current;
if (v && v.state.doc.toString() !== value) {
if (v && v.state.sliceDoc() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
}
}, [value]);
@@ -66,6 +66,7 @@ export function FolderBrowseModal({
role="dialog"
aria-modal="true"
aria-label={t("browse.title")}
data-tour="folder-browse"
>
<div className="border-b border-border p-3">
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
-7
View File
@@ -104,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks;
}
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
// A ring: a surface-4 fill vanished on the light themes.
idle: "border border-fg-muted",
dead: "bg-status-danger",
};
export function since(sec: number | null): string {
if (sec === null) return "—";
if (sec < 60) return `${sec}s`;
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
export function LaneConflictView({
laneId,
session,
source,
path,
onResolved,
onEdit,
@@ -45,6 +46,8 @@ export function LaneConflictView({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
@@ -65,7 +68,7 @@ export function LaneConflictView({
setChoices([]);
setError(null);
api.lanes
.gitFile(gitTarget(laneId, session), path, "work")
.gitFile(gitTarget(laneId, session, source), path, "work")
.then((r) => {
if (!live) return;
setFile(
@@ -78,7 +81,7 @@ export function LaneConflictView({
return () => {
live = false;
};
}, [laneId, session, path]);
}, [laneId, session, source, path]);
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
@@ -102,15 +105,18 @@ export function LaneConflictView({
run(async () => {
if (!parts || !file?.sha) return;
await api.lanes.gitSaveFile(
gitTarget(laneId, session),
gitTarget(laneId, session, source),
path,
applyChoices(parts, choices).join("\n"),
file.sha
);
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
path,
side: "done",
});
});
const takeAll = (side: "ours" | "theirs") =>
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
const choose = (i: number, c: ConflictChoice | undefined) =>
setChoices((prev) => {
+8 -6
View File
@@ -247,7 +247,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
if (hit) return hit;
const lines = hunk.lines;
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
for (let i = 0; i < lines.length;) {
if (lines[i]!.type === "ctx") {
i += 1;
continue;
@@ -440,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
* block's last line. A context rather than a prop because lines render from
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (change: LaneDiffChange) => ReactNode;
/** null = no bar; the anchors still mark each block for navigation. */
actions: ((change: LaneDiffChange) => ReactNode) | null;
changes: LaneDiffChange[];
go: (index: number) => void;
} | null>(null);
@@ -457,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
function HunkBar({ change }: { change: LaneDiffChange }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
if (!ctx?.actions) return null;
const index = ctx.changes.indexOf(change);
const total = ctx.changes.length;
const nav =
@@ -700,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
@@ -931,7 +931,9 @@ export function LaneDiffView({
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
// Provided without actions too: every block's first row carries its
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
+6 -10
View File
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
| "fetch"
| "pull"
| "push"
| "switch"
| "branch-create"
| "branch-delete"
| "merge"
| "publish";
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
@@ -95,6 +88,7 @@ function Btn({
export function LaneGitBar({
laneId,
session,
source,
git,
onChanged,
onNewLane,
@@ -103,6 +97,8 @@ export function LaneGitBar({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -165,7 +161,7 @@ export function LaneGitBar({
setBusy(action);
setError(null);
try {
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
@@ -218,7 +214,7 @@ export function LaneGitBar({
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
+12 -4
View File
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string
session?: string,
/** Read that extra folder's repo instead (`?source=`). */
source?: number
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
@@ -54,7 +56,7 @@ export function useLaneGitFacts(
let alive = true;
const read = () => {
api.lanes
.git(gitTarget(laneId, session))
.git(gitTarget(laneId, session, source))
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
@@ -73,7 +75,7 @@ export function useLaneGitFacts(
alive = false;
clearInterval(timer);
};
}, [laneId, session, nonce]);
}, [laneId, session, source, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
@@ -322,20 +324,23 @@ export function LaneGitCounts({
export function LaneGitRailCounts({
laneId,
session,
source,
onShowAll,
}: {
laneId: number;
session?: string;
source?: number;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
const [git] = useLaneGitFacts(laneId, session);
const [git] = useLaneGitFacts(laneId, session, source);
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
}
export function LaneGitHeader({
laneId,
session,
source,
git,
onChanged,
onNewLane,
@@ -343,6 +348,8 @@ export function LaneGitHeader({
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
@@ -374,6 +381,7 @@ export function LaneGitHeader({
<LaneGitBar
laneId={laneId}
session={session}
source={source}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
@@ -128,12 +128,12 @@ export function SessionReplay({
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} />
<ConversationView sessionId={sessionId} fill />
</div>
{livePane ? (
<div
data-testid="session-replay-live"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
@@ -148,7 +148,7 @@ export function SessionReplay({
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
@@ -160,7 +160,7 @@ export function SessionReplay({
</button>
</div>
) : (
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
@@ -173,6 +173,7 @@ export function SessionReplay({
rows={2}
slash={tool === "claude"}
fileCwd={session?.cwd || lane.cwd}
lane={lane.id}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -52,6 +53,7 @@ export function SessionResumeButton({
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { lanes } = useLanes();
const { canEditLane } = usePermissions();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false);
@@ -77,7 +79,8 @@ export function SessionResumeButton({
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null;
// Server mode: an analyst may resume only in a workspace of their own.
if (!lane || !canEditLane(lane) || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => strip(a) === strip(b);
// Its own worktree counts too: the server resumes it there (`claude
+52 -3
View File
@@ -5,7 +5,8 @@
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
* by default; its last row, **Open terminal**, is no AI — it starts a plain
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
@@ -59,6 +60,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ChatAddMenu } from "../run/ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
@@ -85,6 +88,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
import { readDefaultMode } from "../../lib/sessionDefaults";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -114,12 +118,19 @@ function HomeBody({
lane,
onMenuAction,
onRenamed,
onSourcesChange,
sourcesVersion = 0,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
/** Bumped when the folder list changed elsewhere (the Changes panel's
* Close repository): the Folder list re-reads. */
sourcesVersion?: number;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -148,6 +159,7 @@ function HomeBody({
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const pickFiles = useRef<(() => void) | null>(null);
const [proxy, setProxy] = useSessionProxy();
const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
@@ -173,7 +185,8 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
const [mode, setMode] = useState<PermissionMode>("auto");
// The account's own default (first-run setup) — never bypass.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
@@ -305,10 +318,25 @@ function HomeBody({
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
useEffect(() => {
if (!sourcesVersion) return;
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch(() => undefined);
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
}, [sourcesVersion]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null);
try {
setView(await fn());
const next = await fn();
setView(next);
onSourcesChange?.(next);
return true;
} catch (err) {
setError(message(err));
@@ -529,12 +557,22 @@ function HomeBody({
rows={3}
slash={claude}
fileCwd={lane.cwd}
lane={lane.id}
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
bare
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={lane.cwd}
laneId={lane.id}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
<div data-testid="workspace-home-tool">
<Select<string>
variant="ghost"
@@ -776,6 +814,16 @@ function HomeBody({
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
</button>
)}
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
<button
type="button"
data-testid="workspace-recents-less"
onClick={() => setRecentLimit(RECENT_PAGE)}
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
<div className="mt-2">
<SessionFold
testId={`workspace-archived-${lane.id}`}
@@ -923,6 +971,7 @@ function HomeBody({
}}
/>
</div>
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
{files.length === 0 ? (
<button
type="button"
+18 -11
View File
@@ -8,13 +8,14 @@
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring.
* dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly`
* row (another user's workspace, for an analyst) has no menu, rename or `+`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types";
import { isSubmitEnter } from "../../lib/keys";
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
export function WorkspaceRow({
lane,
index,
readOnly,
selected,
expanded,
renaming,
@@ -63,6 +65,9 @@ export function WorkspaceRow({
lane: Lane;
/** Position in the list — the unit both reorder paths speak in. */
index: number;
/** Set (to its tooltip) for a workspace this user may only read: no menu,
* no rename, no new session. */
readOnly?: string;
selected: boolean;
expanded: boolean;
renaming: boolean;
@@ -82,7 +87,12 @@ export function WorkspaceRow({
const openMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onMenu(e.clientX, e.clientY);
if (!readOnly) onMenu(e.clientX, e.clientY);
};
const startRename = () => {
if (readOnly) return;
setDraft(lane.title || "");
onRenameStart();
};
const Chevron = expanded ? ChevronDown : ChevronRight;
const hoverBtn =
@@ -140,22 +150,18 @@ export function WorkspaceRow({
aria-current={selected ? "true" : undefined}
// A double-click's second click renames instead of opening again.
onClick={(e) => e.detail < 2 && onSelect()}
onDoubleClick={() => {
setDraft(lane.title || "");
onRenameStart();
}}
onDoubleClick={startRename}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
setDraft(lane.title || "");
onRenameStart();
startRename();
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${t("row.hint")}`}
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
@@ -163,7 +169,8 @@ export function WorkspaceRow({
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
</button>
)}
{!renaming && (
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
{!renaming && !readOnly && (
<>
<button
type="button"
@@ -1,7 +1,8 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
* pane above the ended ones) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
@@ -14,16 +15,18 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** while it is open — a closed one
* leaves no row; Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* it in a split in the tab on screen) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context.
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one. A
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text.
* dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -219,6 +222,7 @@ export function WorkspaceSessionList({
selectedSessionId = null,
onMatches,
hidden = false,
readOnly = false,
}: {
lane: Lane;
/** Every live dashboard pane, from `GET /api/run`. */
@@ -234,6 +238,8 @@ export function WorkspaceSessionList({
onMatches?: (count: number) => void;
/** Mounted only to load and count, during a search that hides the row. */
hidden?: boolean;
/** A workspace this user may only read. */
readOnly?: boolean;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -277,10 +283,12 @@ export function WorkspaceSessionList({
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
);
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest
// first (the sort is stable over the server's order).
const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
@@ -312,7 +320,7 @@ export function WorkspaceSessionList({
};
}, [orphanIds]);
const fresh =
sessions === null || q
sessions === null || q || readOnly
? []
: [
...laneRuns.filter(
@@ -489,6 +497,7 @@ export function WorkspaceSessionList({
// A double-click's second click renames; it must not
// navigate (a second history entry) on top of the first.
if (e.detail > 1) return;
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
navigate(
run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
@@ -498,19 +507,19 @@ export function WorkspaceSessionList({
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
);
}}
onDoubleClick={() => startRename(s)}
onDoubleClick={() => !readOnly && startRename(s)}
onKeyDown={(e) => {
if (e.key === "F2") {
if (e.key === "F2" && !readOnly) {
e.preventDefault();
startRename(s);
}
}}
onContextMenu={(e) => sessionCtx.open(e, s)}
onContextMenu={readOnly ? undefined : (e) => sessionCtx.open(e, s)}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s)
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
}${readOnly ? "" : `\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
@@ -536,21 +545,23 @@ export function WorkspaceSessionList({
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/>
</button>
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
// Invisible, not `hidden`: a keyboard user must be able to
// Tab onto it, and it shows when focused.
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<Archive className="h-3.5 w-3.5" />
</button>
{!readOnly && (
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
// Invisible, not `hidden`: a keyboard user must be able to
// Tab onto it, and it shows when focused.
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<Archive className="h-3.5 w-3.5" />
</button>
)}
</li>
);
})}
@@ -565,6 +576,16 @@ export function WorkspaceSessionList({
{t("tree.seeAll", { count: more })}
</button>
)}
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
<button
type="button"
data-testid={`workspace-sessions-less-${lane.id}`}
onClick={() => setLimit(SESSION_LIMIT)}
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
{failure}
{confirmArchive}
{ctx.menu && (
@@ -582,6 +582,19 @@ describe("AddLaneModal — folder browse", () => {
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("opens the picker on every click into the path field", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
renderModal();
const user = userEvent.setup();
const field = document.getElementById("add-lane-repo")!;
await user.click(field);
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
await user.click(field);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
});
it("falls back to the in-app browser when no native picker is available", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
@@ -1,11 +1,13 @@
/**
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
* count as edited in place (blue) rather than added or removed whole, and the
* editor's own diff classes on a real mount, and the per-block Undo / Keep bar.
* editor's own diff classes on a real mount, the per-block Undo / Keep bar,
* and the Add to Chat chip on a selection.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
import DiffEditor, { keepsText } from "../DiffEditor";
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
@@ -166,7 +168,39 @@ describe("DiffEditor", () => {
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
});
it("puts an Undo / Keep bar under each block, by line, in place of Revert", async () => {
it("annotates each line in a gutter, and hands a click its commit", async () => {
const onBlameClick = vi.fn();
const props = {
value: "a\nnew",
original: "a",
lang: "plain",
label: "Edit",
revertLabel: "Revert",
onChange: vi.fn(),
onSave: vi.fn(),
onBlameClick,
};
const sha = "f".repeat(40);
const blame = {
available: true as const,
path: "x",
commits: {
[sha]: { short: "fffffff", author: "Vi", date: "2026-10-01T00:00:00Z", summary: "s" },
},
lines: [sha, null],
};
const { container, rerender } = render(<DiffEditor {...props} />);
expect(container.querySelector(".cm-ah-blame")).toBeNull();
rerender(<DiffEditor {...props} blame={blame} />);
await waitFor(() => expect(container.querySelector(".cm-ah-blame")).not.toBeNull());
// The first element is CodeMirror's invisible width spacer.
const cells = [...container.querySelectorAll(".cm-ah-blame .cm-gutterElement span")].slice(1);
expect(cells.map((c) => c.textContent)).toEqual(["2026-10-01 Vi", "Not committed yet"]);
fireEvent.mouseDown(cells[0]!.parentElement!);
expect(onBlameClick).toHaveBeenCalledWith(sha);
});
it("puts an Undo + commit-checkbox bar under each block, by line, in place of Revert", async () => {
const onBlock = vi.fn();
const props = {
value: "a\nB\nc\nnew\ne",
@@ -177,7 +211,9 @@ describe("DiffEditor", () => {
onChange: vi.fn(),
onSave: vi.fn(),
};
const { container, rerender } = render(<DiffEditor {...props} onBlock={onBlock} />);
const { container, rerender } = render(
<DiffEditor {...props} onBlock={onBlock} blockIncluded={(b) => b.oldFrom === 2} />
);
await waitFor(() =>
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
);
@@ -188,8 +224,10 @@ describe("DiffEditor", () => {
const [first, second] = [
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
];
fireEvent.click(second!.querySelectorAll("button")[1]!);
expect(onBlock).toHaveBeenLastCalledWith("keep", {
const boxes = [first, second].map((b) => b!.querySelector<HTMLInputElement>("input")!);
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
fireEvent.click(boxes[1]!);
expect(onBlock).toHaveBeenLastCalledWith("include", {
oldFrom: 4,
oldCount: 1,
newFrom: 4,
@@ -216,4 +254,74 @@ describe("DiffEditor", () => {
"false"
);
});
it.each(["unified", "split"] as const)(
"keeps a CRLF file's text as is (%s): no edit at mount, so the bars stay",
async (layout) => {
const onChange = vi.fn();
const { container } = render(
<DiffEditor
layout={layout}
value={"a\r\nB\r\nc"}
original={"a\r\nb\r\nc"}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={onChange}
onSave={vi.fn()}
onBlock={vi.fn()}
/>
);
await waitFor(() =>
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(1)
);
expect(onChange).not.toHaveBeenCalled();
const eds = container.querySelectorAll<HTMLElement>(".cm-editor");
const view = EditorView.findFromDOM(eds[eds.length - 1]!)!;
expect(view.state.sliceDoc()).toBe("a\r\nB\r\nc");
// Only B changed: the CRs are not read as an edit on every line.
expect(
container
.querySelectorAll(".cm-editor")
[eds.length - 1]!.querySelectorAll(".cm-changedLine")
).toHaveLength(1);
}
);
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
const onAddToChat = vi.fn();
const { container } = render(
<DiffEditor
value={"a\nb\nc\nd"}
original={"a\nb\nc\nd"}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
onAddToChat={onAddToChat}
/>
);
const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull();
// b..c, ending at the start of d: d is not taken.
view.dispatch({ selection: { anchor: 2, head: 6 } });
const chip = await waitFor(() => {
const b = container.querySelector<HTMLButtonElement>(
'[data-testid="lane-editor-add-to-chat"]'
);
expect(b).not.toBeNull();
return b!;
});
// Inline at the end of the first selected line, never a tooltip over the
// line above it.
expect(chip.closest(".cm-tooltip")).toBeNull();
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
await waitFor(() =>
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull()
);
});
});
@@ -6,7 +6,11 @@
* once, the range toggle changes which comparison is requested, History opens
* one commit, and Files quick-opens a whole file. Also pins the per-file git
* writes — including discard needing two clicks before the server hears
* `confirm` — and the degrade for a cwd git cannot read.
* `confirm` — the JetBrains commit (ticked files, Changes ticked and
* Unversioned Files not by default, Commit and Push, Add to VCS / .gitignore,
* a change block's own checkbox making a hunk-level commit), a file's
* history, Annotate, next/previous file, and the degrade for a cwd git
* cannot read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -70,6 +74,7 @@ vi.mock("../../../lib/api", () => ({
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
gitBlame: vi.fn(),
},
},
}));
@@ -125,6 +130,11 @@ async function toDiff(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
}
/** The overview row's checkbox for one path. */
function tickOf(path: string) {
return screen.getByRole("checkbox", { name: `Include ${path} in the commit` });
}
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
@@ -133,7 +143,6 @@ function discardOf(path: string) {
}
beforeEach(() => {
window.localStorage.removeItem("agenthub.changes.neverStage.7");
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -246,6 +255,36 @@ describe("LaneChangesPanel", () => {
expect(view).toHaveTextContent("keep");
});
it("opens a file asked for from outside (a chat's Edited files card), once per request", async () => {
const view = render(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
vi.mocked(api.lanes.changes).mockClear();
// The same request again changes nothing; a new nonce opens the next one.
view.rerender(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
view.rerender(<LaneChangesPanel laneId={7} openRequest={{ path: "src/main.ts", nonce: 2 }} />);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "src/main.ts",
context: 3,
})
);
expect(api.lanes.changes).not.toHaveBeenCalledWith(
7,
expect.objectContaining({ path: "server/lib/git-ops.js" })
);
});
it("asks for every file's hunks in review mode", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -280,60 +319,36 @@ describe("LaneChangesPanel", () => {
).toBeInTheDocument();
});
it("stages one file and asks the server for that path alone", async () => {
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// Both rows offer it (one modified, one untracked); the first is the
// modified file the assertion below names.
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("keeps a never-stage file out of Stage all, without telling git", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
await user.click(keep!); // the first row: server/lib/git-ops.js
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
"server/lib/git-ops.js",
]);
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
// Only the untracked row offers it: a tracked file is already in VCS.
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
expect(more).toHaveLength(0);
expect(tickOf("notes.txt")).not.toBeChecked();
await user.click(add!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
// And back: the row's button now offers the way out.
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
});
it("draws a never-stage file orange in the Files tree", async () => {
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
],
truncated: false,
});
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
expect(await row("notes.txt")).toHaveClass("text-kept");
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
expect(await row("a.txt")).toHaveClass("text-status-warning");
await user.click(tickOf("docs/API.md"));
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
expect(group).toBePartiallyChecked();
await user.click(group); // some ticked -> untick all
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(group); // -> tick all
expect(tickOf("docs/API.md")).toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
it("lists changes as a folder tree: a folder folds, and its box unticks every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
@@ -341,16 +356,15 @@ describe("LaneChangesPanel", () => {
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib",
});
expect(box).toBeChecked();
await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
@@ -373,7 +387,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
expect(api.lanes.git).toHaveBeenCalledTimes(1);
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
// The header's counts moved with the index: waiting out its 30s poll
// would leave them wrong beside the list that just changed.
@@ -420,43 +434,61 @@ describe("LaneChangesPanel", () => {
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
});
it("stages every file the index lacks in one call, from above the list", async () => {
it("commits exactly the ticked files: Changes ticked, Unversioned Files not, by default", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const actions = screen.getByTestId("lane-changes-actions");
// Commit box and Stage all come BEFORE the file list.
// The commit box comes BEFORE the file list.
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
// The already-staged docs/API.md is not sent again.
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
await user.click(tickOf("notes.txt"));
await user.click(tickOf("docs/API.md"));
await user.type(screen.getByLabelText("Commit message"), "fix: picked");
await user.click(screen.getByRole("button", { name: "Commit (2)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: picked",
paths: ["server/lib/git-ops.js", "notes.txt"],
});
});
it("Commit and Push commits the ticked files, then pushes", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "feat: ship");
await user.click(screen.getByRole("button", { name: "Commit and Push" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledTimes(2));
expect(vi.mocked(api.lanes.gitAction).mock.calls).toEqual([
[7, "commit", { message: "feat: ship", paths: ["server/lib/git-ops.js", "docs/API.md"] }],
[7, "push", {}],
]);
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7, [
"server/lib/git-ops.js",
"docs/API.md",
]);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
it("lists tracked changes apart from Unversioned Files, staged or not", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(tracked).queryByText("notes.txt")).toBeNull();
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
@@ -627,21 +659,21 @@ describe("LaneChangesPanel", () => {
});
});
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
// Commit File: that file alone is ticked, and the message box has focus.
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
vi.mocked(api.lanes.gitAction).mockClear();
await user.click(menu().getByText("Commit File…"));
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(tickOf("docs/API.md")).not.toBeChecked();
expect(screen.getByLabelText("Commit message")).toHaveFocus();
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
await right();
await user.click(screen.getByTestId("lane-changes-menu-discard"));
@@ -654,10 +686,123 @@ describe("LaneChangesPanel", () => {
});
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
);
await user.click(menu().getByText("Copy relative path"));
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
// Copy as Patch: that file's diff, read for its path, as a unified patch.
vi.mocked(api.lanes.changes).mockResolvedValueOnce(
changes({
files: [{ ...changes().files![0]!, hunks: [HUNK] } as never],
})
);
await right();
await user.click(menu().getByText("Copy as Patch to Clipboard"));
await waitFor(async () =>
expect(await navigator.clipboard.readText()).toBe(
[
"diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js",
"--- a/server/lib/git-ops.js",
"+++ b/server/lib/git-ops.js",
"@@ -1,2 +1,3 @@",
" keep",
"-old line",
"+new line",
"",
].join("\n")
)
);
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
});
});
it("right-click on an unversioned file offers Add to VCS and Add to .gitignore", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("notes.txt");
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(menu().getByText("Add to .gitignore"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "ignore", { paths: ["notes.txt"] });
await user.pointer({ keys: "[MouseRight]", target: row });
expect(menu().getByText("Add to VCS")).toBeInTheDocument();
// A tracked file is already in VCS, and git would not ignore it anyway.
await user.keyboard("{Escape}");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("docs/API.md"),
});
expect(menu().queryByText("Add to .gitignore")).toBeNull();
expect(menu().queryByText("Add to VCS")).toBeNull();
});
it("Show History lists one file's commits and opens the file at a commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("server/lib/git-ops.js"),
});
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
);
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 50,
file: "server/lib/git-ops.js",
})
);
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
"server/lib/git-ops.js"
);
await user.click(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "commit",
sha: "a".repeat(40),
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("walks to the next file from an open one, in list order", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Next file" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "work",
path: "notes.txt",
context: 3,
})
);
expect(screen.getByRole("button", { name: "Previous file" })).toBeEnabled();
});
it("annotates the open file with who last changed each line", async () => {
vi.mocked(api.lanes.gitBlame).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
commits: {},
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Annotate" }));
expect(api.lanes.gitBlame).toHaveBeenCalledWith(7, "server/lib/git-ops.js");
expect(screen.getByRole("button", { name: "Annotate" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
@@ -851,7 +996,10 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByTestId("lane-changes-amend"));
await user.click(screen.getByRole("button", { name: "Amend" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", { sha: "aaaaaaa" })
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", {
sha: "aaaaaaa",
paths: ["server/lib/git-ops.js", "docs/API.md"],
})
);
});
@@ -938,7 +1086,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
it("colours the Files tree like JetBrains: modified blue, unversioned orange, added green", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -948,6 +1096,8 @@ describe("LaneChangesPanel", () => {
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
{ name: "keep.txt", path: "keep.txt", dir: false },
{ name: "add.txt", path: "add.txt", dir: false, status: "A " },
{ name: "gone.txt", path: "gone.txt", dir: false, status: " D" },
],
truncated: false,
});
@@ -958,9 +1108,12 @@ describe("LaneChangesPanel", () => {
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
expect(await row("a.txt")).toHaveClass("text-status-warning");
expect(await row("a.txt")).toHaveClass("text-diff-mod");
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
expect(await row("new.txt")).toHaveClass("text-status-success");
expect(await row("new.txt")).toHaveClass("text-kept");
expect(await row("add.txt")).toHaveClass("text-status-success");
expect(await row("gone.txt")).toHaveClass("text-fg-muted");
expect(await row("src")).toHaveClass("text-diff-mod");
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
@@ -1069,18 +1222,6 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
});
it("unstages every staged file in one call, in the same pinned bar as Stage all and commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const row = screen.getByTestId("lane-changes-actions");
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
});
it("switches the diff to side-by-side", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -1157,22 +1298,14 @@ describe("LaneChangesPanel", () => {
);
});
it("commits the index from the overview, never the whole tree", async () => {
it("cannot commit with nothing ticked", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// The box exists only because a file is staged — with a clean index there
// is nothing this button could mean.
const box = await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
expect(box).toBeInTheDocument();
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: the staged half",
only: "staged",
});
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
});
it("drops a half-typed commit message on a lane switch", async () => {
@@ -1185,24 +1318,10 @@ describe("LaneChangesPanel", () => {
expect(screen.getByLabelText("Commit message")).toHaveValue("");
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
it("hides the commit box when there is nothing to commit", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await screen.findByText("Nothing uncommitted.");
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
@@ -1292,22 +1411,7 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-review");
};
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked one has none to act on.
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
expect(stage).toHaveLength(2);
await user.click(stage[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -9,2 +10,3 @@",
block: 0,
context: 3,
});
});
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
@@ -1350,13 +1454,35 @@ describe("LaneChangesPanel", () => {
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,4 +1,5 @@",
block: 1,
context: 3,
});
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
expect(boxes).toHaveLength(2);
await user.click(boxes[1]!);
expect(boxes[0]).toBeChecked();
expect(boxes[1]).not.toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
const user = userEvent.setup();
await openReview(user);
// The second hunk's only block unticked: the file is partial.
await user.click(
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
);
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
await user.click(screen.getByRole("button", { name: "Changes" }));
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: half");
await user.click(screen.getByRole("button", { name: "Commit (1)" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: half",
paths: [],
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
context: 3,
})
);
});
it("hangs one review bar under each actionable hunk, counting them", async () => {
@@ -1398,14 +1524,18 @@ describe("LaneChangesPanel", () => {
});
});
it("stages a whole file from its review header without folding it", async () => {
it("unticks a whole file from its review header without folding it", async () => {
const user = userEvent.setup();
await openReview(user);
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stageFile!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
await user.click(tickOf("server/lib/git-ops.js"));
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
// Every block of it follows the file.
for (const box of screen.getAllByRole("checkbox", {
name: "Include this change in the commit",
}))
expect(box).not.toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
).toBeInTheDocument();
@@ -1615,7 +1745,7 @@ describe("LaneChangesPanel", () => {
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
rerender(<LaneChangesPanel laneId={8} />);
@@ -1732,6 +1862,8 @@ describe("a merge in progress (issue #45)", () => {
// Not offered as an ordinary stage: that would mark it resolved blind.
expect(screen.getAllByText("a.txt")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
// A partial commit cannot finish a merge: Complete is the way out.
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
await waitFor(() =>
@@ -397,6 +397,24 @@ describe("WorkspaceHome", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
});
it("shows more recents a page at a time, and collapses them again", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => {
const all = (params as { archived?: boolean }).archived
? []
: Array.from({ length: 35 }, (_, i) => ({ ...sessions[1]!, id: `s-${i}` }));
const limit = (params as { limit?: number }).limit ?? 30;
return { sessions: all.slice(0, limit), total: all.length, limit, offset: 0 };
});
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByTestId("workspace-recents-more"));
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 60 });
await user.click(await screen.findByTestId("workspace-recents-less"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 30 });
expect(await screen.findByTestId("workspace-recents-more")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-recents-less")).toBeNull();
});
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
const user = userEvent.setup();
renderHome();
+2 -2
View File
@@ -262,8 +262,8 @@ export function splitDiff({
collapse?: { margin: number; minSize: number };
revertLabel: string;
}): { a: EditorView; b: EditorView; destroy: () => void } {
const origDoc = Text.of(original.split("\n"));
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
const origDoc = Text.of(original.split(/\r?\n/));
const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
const root = parent.appendChild(document.createElement("div"));
root.className = "flex h-full min-h-0";
@@ -0,0 +1,239 @@
/**
* @file OnboardingTour.tsx
* @description The step-by-step spotlight tour after the first-run setup
* (docs/plans/onboarding): it dims the page around ONE real control and says
* what to do with it — Add workspace → the folder → Add → the prompt box →
* the live session → done. The overlay never takes a click (the user works
* the real UI); a step moves on when what it asked for has happened (the
* dialog opened, the workspace exists, the session started), and steps back
* when the user backed out (the dialog closed with nothing made). Targets are
* existing `data-testid`s plus `data-tour` marks in AddLaneModal and
* FolderBrowseModal. The
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
* account and survives a reload; `done` ends it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
const KEY = "agenthub.tour";
const EVENT = "agenthub-tour";
const POLL_MS = 250;
type StepId = "add" | "folder" | "create" | "prompt" | "session" | "finish";
const ORDER: StepId[] = ["add", "folder", "create", "prompt", "session", "finish"];
const $ = (sel: string) => document.querySelector(sel);
/** An element that is on screen (a console collapsed with CSS has no box). */
function shown(sel: string): Element | null {
const el = $(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 ? el : null;
}
const modalOpen = () => !!$('[data-tour="add-lane"]');
const composer = () => shown('[data-testid="workspace-home-composer"]');
const sessionLive = () =>
!!new URLSearchParams(window.location.search).get("run") && !!shown('[data-testid="pane-grid"]');
/** Where each step points, and what moves it on (or back). */
const STEPS: Record<
StepId,
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
> = {
add: {
// The sidebar's +; folded to a rail, its tree icon (which unfolds it).
target: () =>
shown('[data-testid="workspace-tree-add"]') || shown('[data-testid="sidebar-tree-rail"]'),
next: () => (modalOpen() ? "folder" : null),
},
folder: {
// The folder browser while it is open: it sits over the field.
target: () => $('[data-tour="folder-browse"]') || $('[data-tour="add-lane-folder"]'),
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
info: true,
},
create: {
target: () => $('[data-tour="add-lane-create"]'),
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
},
prompt: {
target: composer,
next: () => (sessionLive() ? "session" : null),
},
session: { target: () => shown('[data-testid="pane-grid"]'), info: true },
finish: { target: () => null, info: true },
};
function read(): StepId | null {
try {
const v = localStorage.getItem(KEY);
return (ORDER as string[]).includes(v ?? "") ? (v as StepId) : null;
} catch {
return null;
}
}
function write(v: StepId | "done") {
try {
localStorage.setItem(KEY, v);
} catch {
/* storage blocked: the tour just ends with the page */
}
window.dispatchEvent(new Event(EVENT));
}
/** Begin the tour at its first step (the setup's last button). */
export const startTour = () => write("add");
/** Mounted once in the layout; renders nothing unless a tour is running. */
export function OnboardingTour() {
const { t } = useTranslation("onboarding");
const navigate = useNavigate();
const location = useLocation();
const [step, setStep] = useState<StepId | null>(read);
const [rect, setRect] = useState<DOMRect | null>(null);
// The dialog the target sits in, if any: the card goes beside the dialog.
const [box, setBox] = useState<DOMRect | null>(null);
useEffect(() => {
const sync = () => setStep(read());
window.addEventListener(EVENT, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(EVENT, sync);
window.removeEventListener("storage", sync);
};
}, []);
useEffect(() => {
if (!step) return;
const tick = () => {
const def = STEPS[step];
const moved = def.next?.();
if (moved && moved !== step) return write(moved);
const el = def.target();
let r = el ? el.getBoundingClientRect() : null;
// The folder field's suggestions hang below it: light them too.
const list = el?.querySelector("[data-cwd-suggestions]")?.getBoundingClientRect();
if (r && list) {
const top = Math.min(r.top, list.top);
const left = Math.min(r.left, list.left);
r = new DOMRect(
left,
top,
Math.max(r.right, list.right) - left,
Math.max(r.bottom, list.bottom) - top
);
}
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
// Only when it moved: a new DOMRect every tick would re-render forever.
setRect((prev) =>
prev &&
r &&
prev.x === r.x &&
prev.y === r.y &&
prev.width === r.width &&
prev.height === r.height
? prev
: r
);
};
tick();
const id = window.setInterval(tick, POLL_MS);
return () => window.clearInterval(id);
}, [step, location]);
if (!step) return null;
const n = ORDER.indexOf(step) + 1;
const end = () => write("done");
const advance = () => {
if (step === "folder") return write("create");
if (step === "session") return write("finish");
end();
};
const lost = !rect && step !== "finish";
// Beside a narrow target (a field in a dialog: below it, the card would
// cover the rest of the dialog), else under it when there is room, else
// above; centred when there is no target (the finish, or the page moved).
const W = 320;
let style: React.CSSProperties = { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
if (rect) {
const right = (box ?? rect).right;
const top = Math.min(Math.max(12, rect.top), window.innerHeight - 220);
const left = Math.min(Math.max(12, rect.left), window.innerWidth - W - 12);
if (rect.width < window.innerWidth / 2 && right + W + 24 < window.innerWidth) {
style = { left: right + 16, top };
} else if (rect.bottom + 180 < window.innerHeight) {
style = { left, top: rect.bottom + 12 };
} else {
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
}
}
return createPortal(
<div className="pointer-events-none fixed inset-0 z-[70]" data-testid="onboarding-tour">
{rect ? (
<div
aria-hidden
className="absolute rounded-lg ring-2 ring-accent transition-all duration-200"
style={{
left: rect.left - 6,
top: rect.top - 6,
width: rect.width + 12,
height: rect.height + 12,
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)",
}}
/>
) : (
step === "finish" && <div aria-hidden className="absolute inset-0 bg-black/55" />
)}
<div
role="dialog"
aria-live="polite"
aria-label={t(`tour.${step}.title`)}
className="pointer-events-auto absolute w-80 space-y-2 rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
style={style}
>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
{t("tour.progress", { n, total: ORDER.length })}
</p>
<p className="text-sm font-semibold text-fg-primary">{t(`tour.${step}.title`)}</p>
<p className="text-xs leading-relaxed text-fg-secondary">{t(`tour.${step}.body`)}</p>
<div className="flex items-center gap-2 pt-1">
{step !== "finish" && (
<button type="button" onClick={end} className="btn-ghost px-0 text-xs">
{t("tour.skip")}
</button>
)}
<span className="flex-1" />
{lost && !location.pathname.startsWith("/workspace") && (
<button
type="button"
onClick={() => navigate("/workspace")}
className="btn-ghost border border-border text-xs"
>
{t("tour.goWorkspaces")}
</button>
)}
{STEPS[step].info && (
<button
type="button"
onClick={advance}
data-testid="onboarding-tour-next"
className="btn-primary text-xs"
>
{step === "finish" ? t("tour.done") : t("tour.next")}
</button>
)}
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,543 @@
/**
* @file OnboardingWizard.tsx
* @description The first-run setup, once per ACCOUNT (docs/plans/onboarding;
* modelled on Orca's welcome): 1 the default agent — the AI tools detected on
* this machine, the rest behind "show more" — and the permission mode a new
* session starts in (never bypass: that stays per session); 2 the look —
* System / Dark / Light, a theme for each mode, language and terminal font;
* 3 the corner pet (`PetSettings`); 4 notifications — permission, sound,
* a test. Every choice is the same localStorage write Settings makes, so it
* applies at once and is synced to the account (`lib/userPrefs.ts`). Shown
* when the account has not been through it and has no workspace yet (one that
* has is marked done silently), or when Settings asks for it again. The last
* step hands over to the tour (`OnboardingTour`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Check, ChevronLeft, Monitor, Moon, Play, Sun, Zap } from "lucide-react";
import { api } from "../../lib/api";
import { useDialog } from "../../hooks/useDialog";
import { useAiTools } from "../../hooks/useAiTools";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { AiToolIcon } from "../AiToolIcon";
import { Switch } from "../Switch";
import { SettingsSegmented } from "../settings/SettingsRow";
import { PetSettings } from "../settings/PetSettings";
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
import {
DEFAULT_MODES,
readDefaultMode,
readDefaultTool,
writeDefaultMode,
writeDefaultTool,
type DefaultMode,
} from "../../lib/sessionDefaults";
import {
TERMINAL_FONTS,
readTerminalFont,
writeTerminalFont,
type TerminalFont,
} from "../../lib/terminalFont";
import {
NOTIF_SOUNDS,
loadNotifPrefs,
saveNotifPrefs,
type NotifPrefs,
type NotifSound,
} from "../../lib/notifPrefs";
import { notify, playSound } from "../../lib/notify";
import { subscribeToPush } from "../../lib/push";
import { startTour } from "./OnboardingTour";
const STEPS = ["agent", "look", "pet", "notify"] as const;
type Step = (typeof STEPS)[number];
/** Mounted once in the layout: decides whether this account sees the setup. */
export function OnboardingWizard() {
const { loaded, onboardedAt, forced } = useOnboarding();
const [show, setShow] = useState(false);
useEffect(() => {
if (!loaded || onboardedAt) return setShow(false);
if (forced) return setShow(true);
let alive = true;
api.lanes
.list()
.then((r) => {
if (!alive) return;
// An account already at work never needed a welcome.
if (r.lanes.length) void markOnboarded();
else setShow(true);
})
.catch(() => {
/* cannot tell: ask again next load rather than interrupt */
});
return () => {
alive = false;
};
}, [loaded, onboardedAt, forced]);
return show ? <Wizard /> : null;
}
function Wizard() {
const { t } = useTranslation("onboarding");
const navigate = useNavigate();
const [index, setIndex] = useState(0);
const step: Step = STEPS[index] ?? "agent";
const last = index === STEPS.length - 1;
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef);
const skip = () => void markOnboarded();
const next = () => {
if (!last) return setIndex(index + 1);
void markOnboarded();
startTour();
navigate("/");
};
// Ctrl/⌘+Enter continues, like Orca.
const nextRef = useRef(next);
nextRef.current = next;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
nextRef.current();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<div className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
data-testid="onboarding-wizard"
className="flex h-[min(820px,92vh)] w-full max-w-4xl flex-col rounded-2xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
>
<div className="space-y-5 px-8 pt-7">
<div className="flex items-center gap-2.5">
<img src="/favicon.svg" alt="" className="h-7 w-7" />
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
</div>
<div className="flex items-center gap-3" aria-hidden>
{STEPS.map((s, i) => (
<span
key={s}
className={`h-1 w-9 rounded-full ${i <= index ? "bg-fg-primary" : "bg-surface-4"}`}
/>
))}
<span className="text-xs text-fg-muted">
{t("progress", { n: index + 1, total: STEPS.length })}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-6">
{step === "agent" && <AgentStep />}
{step === "look" && <LookStep />}
{step === "pet" && (
<StepHead title={t("pet.title")} body={t("pet.body")}>
<PetSettings />
</StepHead>
)}
{step === "notify" && <NotifyStep />}
</div>
<div className="flex items-center gap-2 border-t border-border px-8 py-4">
<button type="button" onClick={skip} className="btn-ghost text-xs">
{t("skip")}
</button>
<span className="flex-1" />
{index > 0 && (
<button
type="button"
onClick={() => setIndex(index - 1)}
className="btn-ghost border border-border text-xs"
>
<ChevronLeft className="h-3.5 w-3.5" />
{t("back")}
</button>
)}
<button
type="button"
onClick={next}
data-testid="onboarding-next"
className="btn-primary text-xs"
>
{last ? t("finish") : t("continue")}
<kbd className="ml-1 rounded border border-white/30 px-1 text-[10px] font-normal opacity-80">
Ctrl ↵
</kbd>
</button>
</div>
</div>
</div>
);
}
function StepHead({
eyebrow,
title,
body,
children,
}: {
eyebrow?: string;
title: string;
body: string;
children: ReactNode;
}) {
return (
<section className="space-y-6">
<div className="space-y-2">
{eyebrow && (
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{eyebrow}
</p>
)}
<h2 id="onboarding-title" className="text-3xl font-bold text-fg-primary">
{title}
</h2>
<p className="text-sm text-fg-secondary">{body}</p>
</div>
{children}
</section>
);
}
function AgentStep() {
const { t } = useTranslation("onboarding");
const aiTools = useAiTools();
const installed = aiTools.tools.filter((x) => x.installed && x.enabled);
const others = aiTools.tools.filter((x) => !(x.installed && x.enabled));
const [more, setMore] = useState(false);
const [picked, setPicked] = useState<string | null>(readDefaultTool);
const current =
(picked && installed.some((x) => x.id === picked) ? picked : null) ??
aiTools.defaultId ??
installed[0]?.id ??
null;
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
const pick = (id: string) => {
writeDefaultTool(id);
setPicked(id);
};
return (
<StepHead eyebrow={t("agent.eyebrow")} title={t("agent.title")} body={t("agent.body")}>
<div className="space-y-3">
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
{t("agent.detected", { count: installed.length })}
</p>
{aiTools.loaded && !installed.length && (
<p className="text-sm text-fg-muted">{t("agent.none")}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{installed.map((tool) => {
const selected = tool.id === current;
return (
<button
key={tool.id}
type="button"
onClick={() => pick(tool.id)}
aria-pressed={selected}
data-testid={`onboarding-agent-${tool.id}`}
className={`relative flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left transition-colors ${
selected
? "border-accent bg-accent/10 ring-1 ring-accent/50"
: "border-border bg-surface-2 hover:border-border-light"
}`}
>
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-fg-primary">
{tool.label}
</span>
<span className="block truncate font-mono text-xs text-fg-muted">
{tool.command || tool.id}
</span>
</span>
{selected && (
<span className="absolute right-2.5 top-2.5 flex h-5 w-5 items-center justify-center rounded-full bg-accent-solid text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
);
})}
{more &&
others.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-3.5 opacity-60"
>
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
<span className="min-w-0">
<span className="block truncate text-sm text-fg-primary">{tool.label}</span>
<span className="block text-xs text-fg-muted">{t("agent.notInstalled")}</span>
</span>
</div>
))}
</div>
{others.length > 0 && (
<button
type="button"
onClick={() => setMore(!more)}
className="text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{more ? t("agent.less") : `${t("agent.more", { count: others.length })} →`}
</button>
)}
</div>
<div className="space-y-2 rounded-xl border border-border bg-surface-2 px-4 py-3.5">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-fg-primary">{t("agent.permission")}</span>
<SettingsSegmented
ariaLabel={t("agent.permission")}
value={mode}
onChange={(m) => {
writeDefaultMode(m);
setMode(m);
}}
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
/>
</div>
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
</div>
</StepHead>
);
}
const MODE_CARDS: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "system", Icon: Monitor },
{ mode: "dark", Icon: Moon },
{ mode: "light", Icon: Sun },
];
function LookStep() {
const { t, i18n } = useTranslation("onboarding");
const { t: ts } = useTranslation("settings");
const { mode, themeFor, setMode, setThemeFor } = useTheme();
const [font, setFont] = useState<TerminalFont>(readTerminalFont);
return (
<StepHead title={t("look.title")} body={t("look.body")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{MODE_CARDS.map(({ mode: m, Icon }) => {
const selected = mode === m;
return (
<button
key={m}
type="button"
onClick={() => setMode(m)}
aria-pressed={selected}
data-testid={`onboarding-mode-${m}`}
className={`rounded-xl border p-2.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
{m === "system" ? (
<div className="grid grid-cols-2 overflow-hidden rounded-md">
<Swatch mode="dark" name={themeFor.dark} />
<Swatch mode="light" name={themeFor.light} />
</div>
) : (
<Swatch mode={m} name={themeFor[m]} />
)}
<span className="flex items-center gap-1.5 px-1 pt-2 text-sm font-medium text-fg-primary">
<Icon className="h-3.5 w-3.5" />
{t(`look.modes.${m}`)}
<span className="ml-auto text-[11px] font-normal text-fg-muted">
{t(`look.modeHints.${m}`)}
</span>
</span>
</button>
);
})}
</div>
{(["dark", "light"] as Theme[]).map((group) => (
<div key={group} className="space-y-2">
<p className="text-xs font-medium text-fg-secondary">
{t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? ts("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}: ${label}`}
className={`rounded-lg border p-1 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1 text-[11px] text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
))}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium text-fg-secondary">{t("look.language")}</p>
<SettingsSegmented
ariaLabel={t("look.language")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: ts("language.names.en") },
{ value: "vi" as const, label: ts("language.names.vi") },
]}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-fg-secondary">{t("look.font")}</p>
<SettingsSegmented
ariaLabel={t("look.font")}
value={font}
onChange={(f) => {
writeTerminalFont(f);
setFont(f);
}}
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
value: f,
label: (
<span style={{ fontFamily: TERMINAL_FONTS[f] }}>
{ts(`terminalFont.names.${f}`)}
</span>
),
}))}
/>
</div>
</div>
<p className="text-xs text-fg-muted">{t("look.more")}</p>
</StepHead>
);
}
function NotifyStep() {
const { t } = useTranslation("onboarding");
const { t: ts } = useTranslation("settings");
const [prefs, setPrefs] = useState<NotifPrefs>(loadNotifPrefs);
const [result, setResult] = useState<string | null>(null);
const supported = typeof window !== "undefined" && "Notification" in window;
const [permission, setPermission] = useState(() =>
supported ? Notification.permission : "default"
);
const update = (patch: Partial<NotifPrefs>) => {
const next = { ...prefs, ...patch };
saveNotifPrefs(next);
setPrefs(next);
};
const enable = async (on: boolean) => {
if (!on) return update({ enabled: false });
if (supported && Notification.permission !== "granted") {
const perm = await Notification.requestPermission();
setPermission(perm);
if (perm !== "granted") return;
}
update({ enabled: true });
await subscribeToPush().catch(() => {
/* the local fallback still shows them */
});
};
const test = async () => {
const r = await notify(ts("notifications.testTitle"), ts("notifications.testBody"));
setResult(
r.via === "push" || r.via === "native"
? ts("notifications.testSent")
: r.via === "local"
? ts("notifications.testLocal")
: r.via === "blocked"
? ts("notifications.testBlocked")
: ts("notifications.testFailed")
);
};
return (
<StepHead title={t("notify.title")} body={t("notify.body")}>
<div className="space-y-4 rounded-xl border border-border bg-surface-2 px-4 py-4">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg-primary">{t("notify.enable")}</p>
<p className="text-xs text-fg-muted">{t("notify.enableHint")}</p>
</div>
<Switch
checked={prefs.enabled}
onChange={(v) => void enable(v)}
label={t("notify.enable")}
/>
</div>
{permission === "denied" && (
<p className="text-xs text-status-danger">{t("notify.blocked")}</p>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<span className="mr-2 text-sm text-fg-primary">{t("notify.sound")}</span>
<select
aria-label={t("notify.sound")}
value={prefs.sound}
onChange={(e) => {
const sound = e.target.value as NotifSound;
update({ sound });
playSound(sound);
}}
className="select py-1 text-xs"
>
{NOTIF_SOUNDS.map((k) => (
<option key={k} value={k}>
{ts(`notifications.sound.${k}`)}
</option>
))}
</select>
<button
type="button"
onClick={() => playSound(prefs.sound)}
disabled={prefs.sound === "none"}
className="btn-ghost text-xs disabled:opacity-50"
>
<Play className="h-3 w-3" />
{t("notify.preview")}
</button>
<button
type="button"
onClick={() => void test()}
className="btn-ghost border border-border text-xs"
>
<Zap className="h-3 w-3" />
{t("notify.test")}
</button>
</div>
{result && (
<p role="status" className="text-xs text-fg-secondary">
{result}
</p>
)}
</div>
</StepHead>
);
}
@@ -0,0 +1,256 @@
/**
* @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only
* for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const prefsMock = vi.fn();
const saveMock = vi.fn();
const onboardedMock = vi.fn();
const lanesMock = vi.fn();
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
me: {
prefs: () => prefsMock(),
savePrefs: (p: unknown) => saveMock(p),
onboarded: () => onboardedMock(),
},
lanes: { list: () => lanesMock() },
aiTools: {
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
list: async () => ({
default: "claude",
catalog: [],
tools: [
{
id: "claude",
label: "Claude Code",
command: "claude",
installed: true,
enabled: true,
},
{ id: "codex", label: "Codex", command: "codex", installed: true, enabled: true },
{ id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true },
],
}),
},
},
};
});
vi.mock("../../settings/PetSettings", () => ({
PetSettings: () => <div data-testid="pet-settings" />,
}));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools";
beforeEach(() => {
localStorage.clear();
__resetUserPrefs();
__resetAiTools();
prefsMock.mockReset();
saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null });
onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" });
lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} });
});
describe("account preferences", () => {
it("loads the account's values, sends up this browser's, then every change", async () => {
vi.useFakeTimers();
try {
localStorage.setItem("agenthub.terminal.font", "maple");
localStorage.setItem("theme", "dark");
prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null });
await hydrate();
expect(localStorage.getItem("theme")).toBe("light");
await vi.advanceTimersByTimeAsync(10);
expect(saveMock).toHaveBeenCalledWith(
expect.objectContaining({ "agenthub.terminal.font": "maple" })
);
saveMock.mockClear();
localStorage.setItem("agenthub.theme.dark", "darcula");
localStorage.setItem("unrelated.key", "x");
localStorage.removeItem("agenthub.pet");
await vi.advanceTimersByTimeAsync(500);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(saveMock).toHaveBeenCalledWith({
"agenthub.theme.dark": "darcula",
"agenthub.pet": null,
});
} finally {
vi.useRealTimers();
}
});
it("never keeps bypass as the starting permission mode", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto");
writeDefaultMode("bypassPermissions" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan");
});
});
function Probe() {
const s = useOnboarding();
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
}
const renderWizard = () =>
render(
<MemoryRouter>
<OnboardingWizard />
<Probe />
</MemoryRouter>
);
describe("the setup wizard", () => {
it("does not show before the account's preferences load, nor once done", async () => {
renderWizard();
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" });
await act(() => hydrate());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
});
it("skips itself for an account that already has a workspace", async () => {
lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} });
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
});
it("walks four steps, saving each pick, and ends in the tour", async () => {
const user = userEvent.setup();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await screen.findByTestId("onboarding-wizard");
expect(screen.getByText("Pick your default agent")).toBeInTheDocument();
await user.click(await screen.findByTestId("onboarding-agent-codex"));
expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex");
// A tool that is not installed is not offered until asked for, and never pickable.
expect(screen.queryByText("Kimi Code")).toBeNull();
await user.click(screen.getByText(/Show 1 more agents/));
expect(screen.getByText("Kimi Code").closest("button")).toBeNull();
await user.click(screen.getByRole("radio", { name: "Plan" }));
expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan");
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByText("Make it feel like home")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-mode-light"));
expect(localStorage.getItem("theme")).toBe("light");
await user.click(screen.getByRole("button", { name: "Light theme: Canvas" }));
expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas");
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByTestId("pet-settings")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByText("Set up notifications")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Add your first workspace/ }));
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
expect(localStorage.getItem("agenthub.tour")).toBe("add");
});
it("is skipped for good with Skip setup", async () => {
const user = userEvent.setup();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await user.click(await screen.findByRole("button", { name: "Skip setup" }));
await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no"));
expect(onboardedMock).toHaveBeenCalled();
expect(localStorage.getItem("agenthub.tour")).toBeNull();
});
});
describe("the tour", () => {
const mount = (html: string) => {
const host = document.createElement("div");
host.innerHTML = html;
document.body.appendChild(host);
return host;
};
// jsdom has no layout: give the targets a box.
const boxed = () =>
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
x: 10,
y: 10,
left: 10,
top: 10,
width: 100,
height: 30,
right: 110,
bottom: 40,
toJSON: () => ({}),
} as DOMRect);
it("follows the real UI: add → folder → create → prompt", async () => {
const spy = boxed();
const user = userEvent.setup();
const page = mount('<button data-testid="workspace-tree-add">+</button>');
try {
startTour();
render(
<MemoryRouter initialEntries={["/workspace"]}>
<OnboardingTour />
</MemoryRouter>
);
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
// The dialog opened: the tour moves on by itself.
const dialog = mount(
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
);
expect(await screen.findByText("Choose the project folder")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-tour-next"));
expect(screen.getByText("Create it")).toBeInTheDocument();
// Created: the dialog is gone and the workspace's prompt box is there.
dialog.remove();
page.innerHTML = '<div data-testid="workspace-home-composer"></div>';
expect(await screen.findByText("Start your first session")).toBeInTheDocument();
expect(localStorage.getItem("agenthub.tour")).toBe("prompt");
await user.click(screen.getByRole("button", { name: "Skip tour" }));
expect(screen.queryByTestId("onboarding-tour")).toBeNull();
expect(localStorage.getItem("agenthub.tour")).toBe("done");
} finally {
spy.mockRestore();
page.remove();
}
});
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
const spy = boxed();
const page = mount('<button data-testid="workspace-tree-add">+</button>');
try {
localStorage.setItem("agenthub.tour", "create");
render(
<MemoryRouter initialEntries={["/workspace"]}>
<OnboardingTour />
</MemoryRouter>
);
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
} finally {
spy.mockRestore();
page.remove();
}
});
});
+430
View File
@@ -0,0 +1,430 @@
/**
* @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach —
* Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
* `lib/promptAttach.ts`) —, Skills (the user's and the project's)
* and Plugins → one plugin → the skills and commands it ships. Picking one
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ChevronRight,
FileText,
FolderOpen,
History,
Paperclip,
Plug,
Plus,
ScrollText,
Settings2,
} from "lucide-react";
import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
interface Entry {
name: string;
description?: string;
}
interface Lists {
skills: Entry[];
plugins: { name: string; items: Entry[] }[];
}
const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name);
function loadLists(cwd: string): Promise<Lists> {
return Promise.all([
api.ccConfig.skills(undefined, cwd).catch(() => ({ items: [] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
]).then(([sk, pl]) => ({
skills: sk.items
.map((s) => ({ name: s.name, description: s.frontmatter?.description }))
.filter((s, i, all) => all.findIndex((o) => o.name === s.name) === i)
.sort(byName),
plugins: pl.plugins
.filter((p) => p.enabled !== false && p.contributes)
.map((p) => ({
name: p.name,
items: [...p.contributes!.items.skills, ...p.contributes!.items.commands]
.map((it) => ({ name: it.name, description: it.description ?? undefined }))
.filter((it, i, all) => all.findIndex((o) => o.name === it.name) === i)
.sort(byName),
}))
.filter((p, i, all) => p.items.length > 0 && all.findIndex((o) => o.name === p.name) === i)
.sort(byName),
}));
}
const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
function Row({
icon,
label,
more,
active,
onClick,
onHover,
title,
testId,
}: {
icon: ReactNode;
label: string;
more?: boolean;
active?: boolean;
onClick: () => void;
onHover?: () => void;
title?: string;
testId?: string;
}) {
return (
<button
type="button"
role="menuitem"
title={title}
data-testid={testId}
onClick={onClick}
onMouseEnter={onHover}
className={`${ROW} ${active ? "bg-surface-3 text-fg-primary" : ""}`}
>
<span className="shrink-0 text-fg-muted">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{more && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-muted" />}
</button>
);
}
/** Project files: a search of the box's folder, shortest paths first. */
function ProjectFiles({ cwd, onPick }: { cwd: string; onPick: (rel: string) => void }) {
const { t } = useTranslation("run");
const [q, setQ] = useState("");
const [items, setItems] = useState<string[] | null>(null);
useEffect(() => {
let live = true;
const tid = window.setTimeout(() => {
api.run
.files(cwd, q, true)
.then((r) => live && setItems(r.items))
.catch(() => live && setItems([]));
}, 120);
return () => {
live = false;
window.clearTimeout(tid);
};
}, [cwd, q]);
return (
<>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("chat.searchFiles")}
data-testid="chat-add-project-search"
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
/>
<div className="max-h-72 overflow-y-auto">
{!items || items.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-fg-muted">{items ? t("chat.none") : "…"}</p>
) : (
items.map((p) => (
<Row
key={p}
icon={
p.endsWith("/") ? (
<FolderOpen className="h-4 w-4" />
) : (
<FileText className="h-4 w-4" />
)
}
label={p}
title={p}
testId={`chat-add-project-${p}`}
onClick={() => onPick(p)}
/>
))
)}
</div>
</>
);
}
type Sub = "project" | "context" | "recent" | "skills" | "plugins";
export function ChatAddMenu({
cwd,
laneId = null,
skills,
onUpload,
onInsert,
onAttach,
}: {
cwd: string;
/** The workspace, for its Context files; none hides them. */
laneId?: number | null;
/** Offer Skills and Plugins: Claude only, they are Claude Code's. */
skills: boolean;
onUpload: () => void;
/** `/<name>` picked. */
onInsert: (command: string) => void;
/** An existing file picked: a project path, a context file, an upload. */
onAttach: (items: Attachment[]) => void;
}) {
const { t } = useTranslation("run");
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]);
const [plugin, setPlugin] = useState<string | null>(null);
const [lists, setLists] = useState<Lists | null>(null);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
if (skills && !lists) void loadLists(cwd).then(setLists);
const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", away);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("mousedown", away);
document.removeEventListener("keydown", esc);
};
}, [open, skills, lists, cwd]);
// Read again at each opening: either list may have grown since.
useEffect(() => {
if (!open) return;
setRecent(recentUploads());
setContext(null);
if (laneId === null) return;
let live = true;
api.lanes
.sources(laneId)
.then(
(v) =>
live &&
setContext(
v.sources
.filter((s) => s.kind === "file" && !s.missing)
.map((s) => ({ path: `@${s.path}`, name: s.name }))
)
)
.catch(() => live && setContext([]));
return () => {
live = false;
};
}, [open, laneId]);
const close = () => {
setOpen(false);
setSub(null);
setPlugin(null);
};
const pick = (command: string) => {
close();
onInsert(command);
};
const attach = (item: Attachment) => {
close();
onAttach([item]);
};
const manage = (tab: "skills" | "plugins") => {
close();
navigate(`/cc-config?tab=${tab}`);
};
const entries = (items: Entry[], prefix: string, from?: string) =>
items.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-fg-muted">{lists ? t("chat.none") : "…"}</p>
) : (
items.map((it) => (
<Row
key={it.name}
icon={<ScrollText className="h-4 w-4" />}
label={it.name}
title={[it.description, from].filter(Boolean).join("\n") || undefined}
testId={`chat-add-skill-${prefix}${it.name}`}
onClick={() => pick(`/${prefix}${it.name}`)}
/>
))
);
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return (
<div ref={ref} className="relative">
<button
type="button"
data-testid="chat-add"
aria-label={t("chat.add")}
title={t("chat.add")}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
</button>
{open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
<Row
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
testId="chat-add-upload"
onHover={() => setSub(null)}
onClick={() => {
close();
onUpload();
}}
/>
<Row
icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")}
more
active={sub === "project"}
testId="chat-add-project"
onHover={() => setSub("project")}
onClick={() => setSub(sub === "project" ? null : "project")}
/>
{laneId !== null && (
<Row
icon={<FileText className="h-4 w-4" />}
label={t("chat.contextFiles")}
more
active={sub === "context"}
testId="chat-add-context"
onHover={() => setSub("context")}
onClick={() => setSub(sub === "context" ? null : "context")}
/>
)}
<Row
icon={<History className="h-4 w-4" />}
label={t("chat.recentUploads")}
more
active={sub === "recent"}
testId="chat-add-recent"
onHover={() => setSub("recent")}
onClick={() => setSub(sub === "recent" ? null : "recent")}
/>
{skills && (
<>
<Row
icon={<ScrollText className="h-4 w-4" />}
label={t("chat.skills")}
more
active={sub === "skills"}
testId="chat-add-skills"
onHover={() => setSub("skills")}
onClick={() => setSub(sub === "skills" ? null : "skills")}
/>
<Row
icon={<Plug className="h-4 w-4" />}
label={t("chat.plugins")}
more
active={sub === "plugins"}
testId="chat-add-plugins"
onHover={() => setSub("plugins")}
onClick={() => setSub(sub === "plugins" ? null : "plugins")}
/>
</>
)}
{sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div>
)}
{(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
<Row
key={it.path}
icon={<FileText className="h-4 w-4" />}
label={it.name}
title={it.path.replace(/^@/, "")}
testId={`chat-add-${sub}-${it.name}`}
onClick={() => attach(it)}
/>
))
) : (
<p className="px-3 py-1.5 text-xs text-fg-muted">
{sub === "context" && !context ? "…" : t("chat.none")}
</p>
)}
</div>
</div>
)}
{sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" />
<Row
icon={<Settings2 className="h-4 w-4" />}
label={t("chat.manageSkills")}
onClick={() => manage("skills")}
/>
</div>
)}
{sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
)}
{!lists && <p className="px-3 py-1.5 text-xs text-fg-muted">…</p>}
{lists?.plugins.map((p) => (
<Row
key={p.name}
icon={<Plug className="h-4 w-4" />}
label={p.name}
more
active={plugin === p.name}
testId={`chat-add-plugin-${p.name}`}
onHover={() => setPlugin(p.name)}
onClick={() => setPlugin(plugin === p.name ? null : p.name)}
/>
))}
</div>
<div className="my-1 border-t border-border" />
<Row
icon={<Settings2 className="h-4 w-4" />}
label={t("chat.managePlugins")}
onClick={() => manage("plugins")}
/>
{shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{entries(
shownPlugin.items,
`${shownPlugin.name}:`,
t("chat.fromPlugin", { name: shownPlugin.name })
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,260 @@
/**
* @file ChatFilePreview.tsx
* @description A file a Chat pane's session touched, shown the way Claude
* Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the
* content) and `FileListModal` (a list on the left, the picked file's preview
* on the right — Claude's Uploads dialog). Content comes only through routes
* that already guard it: a file inside the workspace through
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Copy, FileText, Loader2, X } from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
/** Where a Chat pane's files can be read from. */
export interface PreviewScope {
laneId: number | null;
/** The workspace's own folder. */
laneCwd: string;
/** The pane's folder — the workspace's, or the session's own worktree. */
cwd: string;
sessionId: string;
}
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
const DROP_RE =
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
export const baseName = (p: string) => p.split("/").pop() || p;
/** `report.v2.xlsx` → `XLSX`; none → "". */
export const extOf = (p: string) => {
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
return m ? m[1]!.toUpperCase() : "";
};
/** An attached file's name without the drop dir's random prefix. */
export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
type Content =
| { kind: "loading" }
| { kind: "image"; src: string }
| { kind: "text"; text: string; truncated: boolean }
| { kind: "none" };
function useContent(path: string, scope: PreviewScope): Content {
const [content, setContent] = useState<Content>({ kind: "loading" });
useEffect(() => {
const drop = DROP_RE.exec(path);
if (drop) {
setContent(
IMAGE_RE.test(path)
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
: { kind: "none" }
);
return;
}
let live = true;
const show = (p: Promise<Content>) => {
setContent({ kind: "loading" });
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
};
const base = scope.cwd.replace(/\/+$/, "");
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
// Claude Code's own files (memory): the config pages' guarded read.
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
show(
api.ccConfig
.file(path)
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
);
} else setContent({ kind: "none" });
return () => {
live = false;
};
}
const rel = path.slice(base.length + 1);
const target =
base === scope.laneCwd.replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return;
}
show(
api.lanes
.gitFile(target, rel)
.then((f) =>
f.available && !f.binary
? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated }
: { kind: "none" }
)
);
return () => {
live = false;
};
}, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]);
return content;
}
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
const { t } = useTranslation("run");
const content = useContent(path, scope);
if (content.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
if (content.kind === "image") {
return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img
src={content.src}
alt={baseName(path)}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
if (content.kind === "none") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p>{t("chat.outline.noPreview")}</p>
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
return (
<div className="min-h-0 flex-1 overflow-auto">
{isMarkdown(path) ? (
<MarkdownPreview text={content.text} className="p-4" />
) : (
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
{content.text}
</pre>
)}
{content.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
)}
</div>
);
}
/** One file: header (name · EXT, Copy path, Close) over its content. */
export function FilePreview({
path,
scope,
onClose,
className = "",
}: {
path: string;
scope: PreviewScope;
onClose: () => void;
className?: string;
}) {
const { t } = useTranslation("run");
return (
<section
data-testid="chat-file-preview"
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
>
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="text-xs text-fg-muted">{extOf(path)}</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
aria-label={t("chat.outline.copyPath", { path })}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onClose}
title={t("chat.outline.close")}
aria-label={t("chat.outline.close")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<Body path={path} scope={scope} />
</section>
);
}
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
export function FileListModal({
title,
paths,
scope,
onClose,
}: {
title: string;
paths: string[];
scope: PreviewScope;
onClose: () => void;
}) {
const [picked, setPicked] = useState(paths[0] ?? "");
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
// Into document.body, like ImageLightbox: a pane's own stacking layer
// would otherwise draw over a `fixed` overlay opened from inside it.
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
data-testid="chat-file-modal"
onClick={(e) => e.stopPropagation()}
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
>
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
{paths.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setPicked(p)}
title={p}
aria-current={p === picked}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
>
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 truncate">{displayName(p)}</span>
</button>
</li>
))}
</ul>
</div>
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
</div>
</div>,
document.body
);
}
+302
View File
@@ -0,0 +1,302 @@
/**
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane shown alone, laid out like
* Claude Desktop's activity panel: the session's Progress (its
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
* this session" — the files attached to its prompts, the files it read and the
* memory files it used (a click opens the list with a preview), its skills
* and MCP servers (a click opens the Claude config page). Read from
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
* uses for the collapsed button's count.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
BookOpen,
Check,
ChevronDown,
FileCode,
FileImage,
FileSearch,
FileSpreadsheet,
FileText,
History,
Paperclip,
Plug,
X,
} from "lucide-react";
import { api, type SessionOutline } from "../../lib/api";
import { displayName, extOf } from "./ChatFilePreview";
const POLL_MS = 5000;
const EMPTY: SessionOutline = {
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
};
/** The session's outline, re-read on a visibility-gated poll. */
export function useSessionOutline(sessionId: string): SessionOutline {
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
useEffect(() => {
let live = true;
const load = () =>
api.sessions
.outline(sessionId)
.then((o) => live && setOutline({ ...EMPTY, ...o }))
.catch(() => {});
void load();
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
return outline;
}
export type OutlineList = "uploads" | "files" | "memory";
function fileIcon(path: string) {
const ext = extOf(path).toLowerCase();
if (["xlsx", "xls", "csv", "tsv"].includes(ext)) return FileSpreadsheet;
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return FileImage;
if (
/^(c|cc|cpp|cs|css|go|h|html|java|js|jsx|json|kt|mjs|cjs|php|py|rb|rs|sh|sql|swift|ts|tsx|vue|ya?ml|toml)$/.test(
ext
)
)
return FileCode;
return FileText;
}
function Section({
title,
count,
action,
grow,
children,
}: {
title: string;
count?: number;
action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */
grow?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className={`flex flex-col px-3 pt-3 ${grow && open ? "min-h-0 flex-1" : ""}`}>
<div className="flex items-center gap-1.5 px-1 pb-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-1.5 text-sm font-medium text-fg-primary"
>
{title}
{count ? <span className="text-xs font-normal text-fg-muted">{count}</span> : null}
<ChevronDown
className={`h-3.5 w-3.5 text-fg-muted transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
<div className="ml-auto flex items-center">{action}</div>
</div>
{open && (
<div
className={
grow
? "min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,transparent,black_8px,black_calc(100%-8px),transparent)]"
: ""
}
>
{children}
</div>
)}
</section>
);
}
const rowClass =
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-[13px] text-fg-secondary enabled:hover:bg-surface-3";
/** A "Used in this session" row: icon, label, the first names, +N. */
function UsedRow({
icon: Icon,
label,
names,
onClick,
to,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
names: string[];
onClick?: () => void;
to?: string;
}) {
const shown = names.slice(0, 2).join(", ");
const inner = (
<>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="flex-shrink-0 text-fg-primary">{label}</span>
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">{shown}</span>
{names.length > 2 && (
<span className="flex-shrink-0 text-xs text-fg-muted">+{names.length - 2}</span>
)}
</>
);
const title = `${label}: ${names.join(", ")}`;
return to ? (
<Link to={to} title={title} className={`${rowClass} hover:bg-surface-3`}>
{inner}
</Link>
) : (
<button type="button" title={title} onClick={onClick} className={rowClass}>
{inner}
</button>
);
}
const stem = (p: string) => displayName(p).replace(/\.[A-Za-z0-9]+$/, "");
export function ChatOutline({
outline: o,
onClose,
onOpenFile,
onOpenList,
}: {
outline: SessionOutline;
onClose: () => void;
onOpenFile: (path: string) => void;
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
type="button"
onClick={onClose}
title={t("chat.outline.hide")}
aria-label={t("chat.outline.hide")}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
);
return (
<aside
data-testid="chat-outline"
aria-label={t("chat.outline.panel")}
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
>
{o.progress.length > 0 && (
<Section title={t("chat.outline.progress")} action={close}>
{o.progress.map((task) => {
const done = task.status === "completed";
return (
<div key={task.id} className={rowClass} title={task.subject}>
{done ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
<Check className="h-3 w-3" />
</span>
) : (
<span
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse border-accent" : "border-border"}`}
/>
)}
<span
className={`min-w-0 flex-1 truncate ${done ? "text-fg-muted line-through" : task.status === "in_progress" ? "text-fg-primary" : ""}`}
>
{task.subject}
</span>
</div>
);
})}
</Section>
)}
<Section
title={t("chat.outline.outputs")}
count={o.outputs.length}
action={o.progress.length ? undefined : close}
grow
>
{o.outputs.length ? (
o.outputs.map((p) => {
const Icon = fileIcon(p);
return (
<button
key={p}
type="button"
title={p}
aria-label={`${extOf(p)}: ${stem(p)}`}
onClick={() => onOpenFile(p)}
className={rowClass}
>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{stem(p)}</span>
<span className="flex-shrink-0 text-[11px] text-fg-muted">{extOf(p)}</span>
</button>
);
})
) : (
<p className="px-1.5 py-1 text-xs text-fg-muted">{t("chat.outline.empty")}</p>
)}
</Section>
{used > 0 && (
<Section title={t("chat.outline.used")}>
{o.uploads.length > 0 && (
<UsedRow
icon={Paperclip}
label={t("chat.outline.uploads")}
names={o.uploads.map(displayName)}
onClick={() => onOpenList("uploads")}
/>
)}
{o.files.length > 0 && (
<UsedRow
icon={FileSearch}
label={t("chat.outline.files")}
names={o.files.map(displayName)}
onClick={() => onOpenList("files")}
/>
)}
{o.memory.length > 0 && (
<UsedRow
icon={History}
label={t("chat.outline.memory")}
names={o.memory.map(stem)}
onClick={() => onOpenList("memory")}
/>
)}
{o.skills.length > 0 && (
<UsedRow
icon={BookOpen}
label={t("chat.skills")}
names={o.skills}
to="/settings/agents/claude-code?tab=skills"
/>
)}
{o.mcp.length > 0 && (
<UsedRow
icon={Plug}
label={t("chat.outline.connectors")}
names={o.mcp}
to="/settings/agents/claude-code?tab=mcp"
/>
)}
</Section>
)}
</aside>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
* a `+` item) brings it into the tab on screen, split to the right, and a
* a `+` item) brings it into the tab on screen, in the same order as a join, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
+33 -2
View File
@@ -12,6 +12,11 @@
* 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
* 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
* conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
* left of the pane's split buttons); the Chat UI setting (Settings →
* Appearance, `lib/chatUi.ts`) is only what a session shows first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +28,10 @@ import type { RunHandle, RunListResponse } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat";
import { useSessionChat } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]);
export interface LaneConsolePaneProps {
lanes: Lane[];
@@ -42,6 +51,8 @@ export interface LaneConsolePaneProps {
onRunExit?: () => void;
/** The terminal takes the keyboard when it appears (default). */
autoFocus?: boolean;
/** The only pane shown: its Chat view gets the right panel. */
solo?: boolean;
}
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
@@ -60,6 +71,7 @@ export function LaneConsolePane({
onNewSession,
onRunExit,
autoFocus,
solo = false,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const [handle, setHandle] = useState<RunHandle | null>(null);
@@ -70,6 +82,12 @@ export function LaneConsolePane({
// The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
// A run learns its session id after it starts (Codex): the page's poll has it.
const chatSession = handle
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
: null;
const chat = useSessionChat(chatSession);
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -173,11 +191,24 @@ export function LaneConsolePane({
)}
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<div className="relative flex-1 min-h-0 flex flex-col">
{/* Over the terminal, never instead of it: unmounting would drop the attach. */}
{chatable && chat && (
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
<PaneChat
run={handle}
sessionId={chatSession}
cwd={handle.cwd || currentLane?.cwd || ""}
laneId={laneId}
laneCwd={currentLane?.cwd || ""}
outline={solo}
/>
</div>
)}
<TerminalView
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus}
autoFocus={autoFocus && !(chatable && chat)}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
+323
View File
@@ -0,0 +1,323 @@
/**
* @file PaneChat.tsx
* @description A live pane's Chat view, the way Claude Desktop shows a session:
* the session's conversation (`ConversationView`, kept current by its own poll)
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
* attached underneath — a message is sent through that terminal
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
* nothing new reaches the server. The box carries Claude Desktop's controls:
* the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
* context files, recent uploads —, skills, plugins' skills), and for a
* Claude pane the Model/Effort chip — a change is typed into the pane as
* `/model <id>` / `/effort <level>` — and the permission mode, which only
* Shift+Tab changes in a running session, so the server steps it and reads
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
* Shown alone (`outline`), it also has Claude Desktop's activity panel
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
* file button with the output count until clicked (remembered per browser);
* a file clicked there opens its preview in the panel's place
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
* the workspace's folder —, a group
* of files a list with a preview (`FileListModal`). A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
* conversation to its end and keeps it there (`follow`), so the message and
* the step running after it come into sight on their own. While the session's
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square } from "lucide-react";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
["default", "Default"],
["acceptEdits", "AcceptEdits"],
["plan", "Plan"],
["auto", "Auto"],
["bypassPermissions", "Bypass"],
];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
/** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
const { t } = useTranslation(["run", "lanes"]);
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
const [busy, setBusy] = useState(false);
const read = useCallback(
() =>
api.run
.permissionMode(runId)
.then((r) => isSettable(r.mode) && setMode(r.mode))
.catch(() => {}),
[runId]
);
useEffect(() => {
void read();
}, [read]);
// Bypass is in a session's cycle only when it was started with it.
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
return (
<div data-testid="pane-chat-mode">
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
disabled={busy}
onChange={(v) => {
setBusy(true);
api.run
.setPermissionMode(runId, v)
.then((r) => setMode(r.mode))
.catch((err: unknown) => {
toast.error(
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
);
void read();
})
.finally(() => setBusy(false));
}}
options={offered.map(([value, key]) => ({
value,
label: t(`lanes:home.permission${key}`),
hint: t(`run:fields.permission${key}`),
}))}
/>
</div>
);
}
export function PaneChat({
run,
sessionId,
cwd,
laneId = null,
laneCwd = "",
outline = false,
}: {
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
sessionId: string;
cwd: string;
/** The workspace and its folder: a file under `cwd` is previewed through it. */
laneId?: number | null;
laneCwd?: string;
/** The pane is shown alone: room for the right panel. */
outline?: boolean;
}) {
const { t } = useTranslation("run");
const runId = run.id;
const claude = run.tool === "claude";
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false);
const [follow, setFollow] = useState(0);
const [model, setModel] = useState(run.model ?? "");
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
const pickFiles = useRef<(() => void) | null>(null);
// Collapsed until asked for, like Claude Desktop's activity panel.
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
const openOutline = (open: boolean) => {
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
setOutlineOpen(open);
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images);
// The main agent is "working" from a prompt's submit to its Stop hook.
const [working, setWorking] = useState(false);
const readWorking = useCallback(
() =>
api.agents
.list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its
// status filter ignored: check the status here.
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
.catch(() => {}),
[sessionId]
);
useEffect(() => {
void readWorking();
}, [readWorking]);
useLiveRefresh(readWorking, {
throttleMs: 500,
// While Stop shows, also poll: a missed agent_updated (the hook reached
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working ? 5000 : undefined,
match: (msg) =>
msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// A control is a command typed into the pane, like the user would.
const command = (line: string) => {
const ok = submitToRun(runId, line);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = () => {
if (!text || uploading) return;
if (!command(text)) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
};
return (
<div className="relative flex min-h-0 flex-1">
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
<div className="relative min-h-0 flex-1 overflow-hidden">
<ConversationView
sessionId={sessionId}
fill
live
follow={follow}
onOpenFile={(path) => {
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview, when it has room for one.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
if (outline) setPreview(path);
}}
/>
{outline && !outlineOpen && !preview && (
<button
type="button"
data-testid="chat-outline-show"
onClick={() => openOutline(true)}
title={t("chat.outline.show")}
aria-label={t("chat.outline.show")}
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
>
<FileText className="h-3.5 w-3.5" />
{activity.outputs.length}
</button>
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
value={prompt}
onChange={setPrompt}
onSubmit={send}
placeholder={t("chat.placeholder")}
rows={2}
slash={claude}
fileCwd={cwd}
lane={runId}
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={cwd}
laneId={laneId}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
<div className="ml-auto flex items-center gap-1">
{claude && (
<ModelMenu
value={model}
onChange={(v) => {
if (command(`/model ${v || "default"}`)) setModel(v);
}}
tool={run.tool}
effort={effort}
onEffortChange={(e) => {
if (command(`/effort ${e || "auto"}`)) setEffort(e);
}}
/>
)}
{working && !text ? (
<button
type="button"
data-testid="pane-chat-stop"
onClick={() => {
// An interrupted turn fires no Stop hook: clear it here.
if (interruptRun(runId)) setWorking(false);
else toast.error(t("chat.notStopped"));
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
>
<Square className="h-3 w-3 fill-current" />
</button>
) : (
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{outline &&
(preview ? (
<FilePreview
path={preview}
scope={scope}
onClose={() => setPreview(null)}
// Beside the chat when there is room, over it when not.
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
)
))}
{list && (
<FileListModal
title={t(`chat.outline.${list}`)}
paths={activity[list]}
scope={scope}
onClose={() => setList(null)}
/>
)}
</div>
);
}
+59 -4
View File
@@ -9,7 +9,8 @@
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or take the pane OUT of this tab (its session keeps running
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
* in a tab of its own — so its icon is not the tab's stop X), or stop its
* session there (X, `onStop`) without taking it out first. The grid shows
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
@@ -28,9 +29,11 @@ import {
ChevronDown,
Columns2,
GripVertical,
MessageSquare,
Minimize2,
Rows2,
SquareTerminal,
X,
} from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -53,6 +56,7 @@ import {
} from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -189,9 +193,37 @@ function PaneMenu({
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";
/** A pane's split-right / split-down menus: move a hidden run beside
* `slot`, or start a new terminal or AI tool there. In a pane header when the view is
* split, beside Tools in the tab bar while one pane is shown. */
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
const sessionId = run.sessionId;
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
return (
<button
type="button"
data-testid={`pane-chat-toggle-${run.slot ?? 0}`}
aria-pressed={chat}
title={label}
aria-label={label}
onClick={() => setSessionChat(sessionId, !chat)}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
{chat ? (
<SquareTerminal className="h-3.5 w-3.5" />
) : (
<MessageSquare className="h-3.5 w-3.5" />
)}
</button>
);
}
/** A pane's chat/terminal toggle and split-right / split-down menus: move a
* hidden run beside `slot`, or start a new terminal or AI tool there. In a
* pane header when the view is split, beside Tools in the tab bar while one
* pane is shown. */
export function SplitMenus({
slot,
shown,
@@ -220,6 +252,7 @@ export function SplitMenus({
const full = shown.length >= MAX_PANES;
return (
<>
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
{(["right", "bottom"] as const).map((edge) => (
<PaneMenu
key={edge}
@@ -309,8 +342,11 @@ export function PaneGrid({
toolLabel,
onNewSplit,
stateOf,
onStop,
}: {
layout: PaneLayout;
/** Stops the session in `slot` (the header's X). */
onStop?: (slot: number) => void;
/** A live AI run's state for its header dot (none for a shell). */
stateOf?: (r: RunHandle) => RunState | null;
/** Every slot to keep mounted; those not in the layout are hidden. */
@@ -441,6 +477,8 @@ export function PaneGrid({
key={slot}
data-testid={`pane-${slot}`}
data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
data-menu-bounds
className={r ? "absolute flex flex-col" : "hidden"}
style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)}
@@ -514,6 +552,18 @@ export function PaneGrid({
>
<Minimize2 className="h-3.5 w-3.5" />
</button>
{run && onStop && (
<button
type="button"
data-testid={`pane-stop-${slot}`}
title={t("panes.stop")}
aria-label={t("panes.stop")}
onClick={() => onStop(slot)}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)}
@@ -557,6 +607,11 @@ export function PaneGrid({
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
{t("panes.closeShort")}
</MenuItem>
{onStop && runOf(paneCtx.menu.item) && (
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
{t("panes.stop")}
</MenuItem>
)}
</ContextMenu>
)}
{dividers.map((d) => (
+96 -32
View File
@@ -6,8 +6,12 @@
* box (`WorkspaceHome.tsx`):
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and pasted or dropped files (images as thumbnails, any other
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a Files panel row
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
* a borderless (`bare`) one grows with its text up to 40vh;
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Proxy's models), plus Custom…;
@@ -19,7 +23,17 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import {
Check,
@@ -42,9 +56,16 @@ import {
} from "../../lib/api";
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
import type { LaneTool } from "../../lib/types";
import { Select } from "../Select";
import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import {
ATTACH_MIME,
addAttachments,
droppedAttachment,
rememberUploads,
} from "../../lib/promptAttach";
export interface SlashCommand {
name: string;
@@ -158,11 +179,16 @@ interface PromptEditorProps {
* sends them with the text through `promptWithImages`. */
images: PromptImage[];
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */
/** Borderless, for a composer box that draws its own frame — and grows
* with its text, from `rows` up to 40% of the screen, then scrolls. */
bare?: boolean;
/** An image upload started (true) or settled (false): the caller holds its
* send button meanwhile, or the prompt would go out without the image. */
onBusyChange?: (busy: boolean) => void;
/** The workspace (id, or a pane's run id) whose panes read the files. */
lane?: number | string;
/** Set to a function that opens a file picker (a composer's + menu). */
pickFiles?: MutableRefObject<(() => void) | null>;
}
/** An attached file: its path on the server's machine, and a local preview
@@ -175,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000;
export function PromptEditor({
value,
onChange,
@@ -187,9 +216,19 @@ export function PromptEditor({
onImagesChange,
bare,
onBusyChange,
lane,
pickFiles,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
// A composer grows with its text: reset to its rows, then take what the
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
useLayoutEffect(() => {
const ta = taRef.current;
if (!bare || !ta) return;
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [bare, value]);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
@@ -321,11 +360,11 @@ export function PromptEditor({
const busyRef = useRef(onBusyChange);
busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => {
const attachImages = (list: FileList | File[] | null | undefined) => {
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropFile(f)))
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
@@ -333,6 +372,7 @@ export function PromptEditor({
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
onImagesChange((prev) => [...prev, ...added]);
})
.catch(() => setUpload("failed"));
@@ -342,12 +382,11 @@ export function PromptEditor({
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
};
useEffect(() => {
if (!viewing) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [viewing]);
const closeViewer = useCallback(() => setViewing(null), []);
// The composer's own "Upload or add files" opens this picker; what it picks
// takes the same path as a paste or a drop.
const fileInput = useRef<HTMLInputElement | null>(null);
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
@@ -451,6 +490,19 @@ export function PromptEditor({
)}
</div>
)}
{pickFiles && (
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="prompt-file-input"
onChange={(e) => {
attachImages(e.target.files);
e.target.value = "";
}}
/>
)}
<textarea
ref={taRef}
value={value}
@@ -464,8 +516,28 @@ export function PromptEditor({
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}
}}
onDragOver={(e) => {
// A Files panel row is not a file: say a drop is welcome.
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
}}
onDrop={(e) => {
const att = droppedAttachment(e.dataTransfer);
if (att) {
e.preventDefault();
onImagesChange((prev) => addAttachments(prev, [att]));
return;
}
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
}}
placeholder={placeholder}
@@ -473,7 +545,7 @@ export function PromptEditor({
spellCheck={false}
className={
bare
? "block w-full resize-none bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
}
/>
@@ -542,20 +614,7 @@ export function PromptEditor({
)}
</div>
)}
{viewing && (
<div
role="dialog"
aria-label={viewing.name}
onClick={() => setViewing(null)}
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={viewing.url}
alt={viewing.name}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>
)}
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
</div>
);
}
@@ -670,7 +729,10 @@ export function CwdAutocomplete({
/>
</div>
{open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
<div
data-cwd-suggestions
className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"
>
{groups.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
) : (
@@ -853,9 +915,11 @@ export function ModelMenu({
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
// Below the chip unless the viewport has more room above it.
const r = ref.current?.getBoundingClientRect();
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
// Below the chip unless its pane (or the viewport) has more room above it.
if (ref.current) {
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
setUp(below < 360 && above > below);
}
const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
+75 -14
View File
@@ -31,7 +31,10 @@
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
* READ the user's clipboard). While the socket is down a "Reconnecting…"
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
* most every 150 ms.
* 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
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
@@ -46,6 +49,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
type DropState = "hint" | "uploading" | "failed";
@@ -188,7 +192,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
@@ -217,7 +221,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// under the canvas renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too.
// its `vietnamese` subset so this covers the diacritics too. Settings →
// Appearance may swap it for the rounded Maple Mono (also complete) or
// the browser's own `monospace` (lib/terminalFont.ts).
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
// cursor to column 0 on a line feed — and xterm's own docs say not to use
// it on a PTY, where termios already does that translation for the streams
@@ -229,7 +235,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const term = new Terminal({
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
fontFamily: TERMINAL_FONTS[readTerminalFont()],
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
fontWeight: 500,
fontWeightBold: 700,
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
});
@@ -325,6 +334,25 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const decoder = new TextDecoder();
let ws: WebSocket | null = null;
// Another browser drives this pane's size: the grid it set, or null.
let follow: { cols: number; rows: number } | null = null;
// A follower's grid may not fit this container: shrink it (never grow).
const scaleToFit = () => {
const el = term.element;
const box = containerRef.current;
if (!el || !box) return;
el.style.transformOrigin = "0 0";
el.style.transform = "";
// `.xterm` is a block as wide as the box; the grid's size is the screen's.
const grid = el.querySelector<HTMLElement>(".xterm-screen");
if (!follow || !grid?.offsetWidth || !grid.offsetHeight) return;
const s = Math.min(
1,
box.clientWidth / grid.offsetWidth,
box.clientHeight / grid.offsetHeight
);
if (s < 1) el.style.transform = `scale(${s})`;
};
/**
* Tell the server this terminal's size. tmux draws the pane at whatever
@@ -339,10 +367,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
* (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing.
*/
const sendSizeNow = () => {
const sendSizeNow = (drive = false) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
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
// pane (and every other viewer) follows it.
const takeLead = () => {
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
follow = null;
scaleToFit();
fit.fit();
sendSizeNow(true);
};
// A divider drag or a window resize fires a resize per frame; tmux and the
// program in the pane redraw on each, so the LAST size is sent once things
@@ -397,7 +435,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (data.startsWith('{"type"')) {
try {
const msg = JSON.parse(data);
if (msg.type === "exit") {
if (msg.type === "size") {
follow = { cols: msg.cols, rows: msg.rows };
term.resize(msg.cols, msg.rows);
requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") {
takeLead();
} else if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
onExitRef.current?.();
@@ -426,6 +470,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
connect();
const dataDisposable = term.onData((data) => {
// A keystroke takes the lead; a mouse report (wheel, hover) does not.
if (follow && !/^\x1b\[(<|M)/.test(data)) takeLead();
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
});
@@ -435,16 +481,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const resizeDisposable = term.onResize(sendSize);
// Re-assert on focus. Several attach clients can share one tmux session
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
// the window to whichever client was last active — resizing this pane out
// from under a terminal that is never told. Coming back to it says so.
const reassert = () => sendSizeNow();
// (a second tab, another browser): coming back to this one makes it the
// driver again, and the others follow its size.
const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
if (follow) return scaleToFit(); // the driver's grid, not ours
fit.fit(); // a real change fires onResize, which reports it
};
const resizeObserver = new ResizeObserver(refit);
@@ -499,17 +545,32 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
() =>
registerPane(
runId,
(text) => {
(text, submit) => {
termRef.current?.paste(text);
termRef.current?.focus();
// Enter a beat after the paste, so a TUI that times its paste
// detection never reads it as a newline inside the paste. A submit
// comes from the pane's Chat view, which keeps the keyboard.
if (submit)
setTimeout(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send("\r");
}, 100);
else termRef.current?.focus();
},
// On screen (a console hidden with CSS measures 0×0) AND attached:
// `onData` drops input while the socket is connecting or reconnecting,
// so a caller waits or says the paste failed instead.
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
(data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
}
),
[runId]
);
// The Workspace's focused pane takes those pastes even when the focus moved
// without a click inside the terminal (a tab, Alt+1–4, the sidebar).
useEffect(() => {
if (autoFocus) notePaneFocus(runId);
}, [autoFocus, runId]);
const runFind = (backwards = false) => {
const q = find ?? "";
@@ -0,0 +1,103 @@
/**
* @file ChatAddMenu.test.tsx
* @description The `+` menu's existing-resource pickers: Project files (a
* search with folders, attached as `@path`), the workspace's Context files
* (files only, none missing) and this browser's Recent uploads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ChatAddMenu } from "../ChatAddMenu";
import { api } from "../../../lib/api";
import { rememberUploads } from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
lanes: {
sources: vi.fn().mockResolvedValue({
sources: [
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
{ id: 2, kind: "file", path: "/data/ctx/1/gone.md", name: "gone.md", missing: true },
{ id: 3, kind: "folder", path: "/srv/docs", name: "docs", missing: false },
],
}),
},
ccConfig: {
skills: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
},
},
};
});
function renderMenu(laneId: number | null = 7) {
const onAttach = vi.fn();
render(
<MemoryRouter>
<ChatAddMenu
cwd="/repo"
laneId={laneId}
skills={false}
onUpload={() => {}}
onInsert={() => {}}
onAttach={onAttach}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("chat-add"));
return onAttach;
}
beforeEach(() => {
i18n.changeLanguage("en");
localStorage.clear();
vi.clearAllMocks();
});
describe("ChatAddMenu — existing resources", () => {
it("searches the project, folders included, and attaches a pick as @path", async () => {
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-project"));
fireEvent.click(await screen.findByTestId("chat-add-project-src/"));
expect(api.run.files).toHaveBeenCalledWith("/repo", "", true);
expect(onAttach).toHaveBeenCalledWith([{ path: "@src/", name: "src/" }]);
});
it("offers the workspace's context files that are still there, files only", async () => {
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-context"));
fireEvent.click(await screen.findByTestId("chat-add-context-brief.md"));
expect(api.lanes.sources).toHaveBeenCalledWith(7);
expect(screen.queryByTestId("chat-add-context-gone.md")).toBeNull();
expect(onAttach).toHaveBeenCalledWith([{ path: "@/data/ctx/1/brief.md", name: "brief.md" }]);
});
it("has no Context files without a workspace", () => {
renderMenu(null);
expect(screen.queryByTestId("chat-add-context")).toBeNull();
});
it("lists recent uploads newest first and attaches one as it was sent", () => {
rememberUploads([{ path: "/tmp/d/old.pdf", name: "old.pdf", image: false }]);
rememberUploads([{ path: "/tmp/d/0123456789abcdef.png", name: "shot.png", image: true }]);
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-recent"));
const rows = screen.getAllByTestId(/^chat-add-recent-/);
expect(rows.map((r) => r.textContent)).toEqual(["shot.png", "old.pdf"]);
fireEvent.click(rows[0]!);
expect(onAttach).toHaveBeenCalledWith([
{
path: "/tmp/d/0123456789abcdef.png",
name: "shot.png",
url: "/api/run/drop/0123456789abcdef.png",
},
]);
});
});
@@ -0,0 +1,78 @@
/**
* @file ChatFilePreview.test.tsx
* @description Where a Chat pane's file preview reads from: a file under the
* pane's folder through the lane's git file read, a memory file under
* `~/.claude` through the config pages' read, anything else by path only; and
* the list dialog previewing the file picked in it.
* @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 { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
gitFile: vi.fn().mockResolvedValue({
available: true,
path: "src/a.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["export const a = 1;"],
}),
gitRawUrl: () => "",
},
ccConfig: {
file: vi
.fn()
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
},
},
};
});
const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
describe("FilePreview", () => {
it("reads a workspace file through the lane", async () => {
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
});
it("reads a memory file through the config read", async () => {
const mem = "/h/.claude/projects/-w/memory/launch.md";
render(<FilePreview path={mem} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("Launch notes")).toBeTruthy();
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
});
it("shows only the path of a file it may not read", () => {
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
});
describe("FileListModal", () => {
it("previews the file picked in the list", async () => {
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
expect(displayName(drop)).toBe("minutes.xlsx");
render(
<FileListModal
title="Uploads"
paths={[drop, "/w/src/a.ts"]}
scope={scope}
onClose={() => {}}
/>
);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
});
});
@@ -0,0 +1,87 @@
/**
* @file ChatOutline.test.tsx
* @description The Chat pane's activity panel renders a session's outline the
* way Claude Desktop does: progress with done/current items, outputs by name
* with an EXT tag (a click opens that file), and "Used in this session" rows —
* uploads, files read and memory open their list, skills link to the config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { ChatOutline } from "../ChatOutline";
const outline = {
progress: [
{ id: "1", subject: "Parse plan", status: "completed" },
{ id: "2", subject: "Build report", status: "in_progress" },
],
outputs: ["/w/out/Progress report.xlsx"],
files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"],
uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"],
memory: ["/h/.claude/projects/-w/memory/release.md"],
skills: ["xlsx"],
mcp: ["vercel_vercel"],
};
describe("ChatOutline", () => {
it("shows progress, outputs and what the session used", () => {
const onClose = vi.fn();
const onOpenFile = vi.fn();
const onOpenList = vi.fn();
render(
<MemoryRouter>
<ChatOutline
outline={outline}
onClose={onClose}
onOpenFile={onOpenFile}
onOpenList={onOpenList}
/>
</MemoryRouter>
);
expect(screen.getByText("Parse plan").className).toContain("line-through");
expect(screen.getByText("Build report").className).not.toContain("line-through");
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
// An upload is named without the drop dir's random prefix.
fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx"));
expect(onOpenList).toHaveBeenCalledWith("uploads");
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
fireEvent.click(screen.getByTitle("Memory: release"));
expect(onOpenList).toHaveBeenCalledWith("memory");
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
"/settings/agents/claude-code?tab=skills"
);
expect(screen.getByText("vercel_vercel")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
expect(onClose).toHaveBeenCalled();
});
it("hides progress and the used section when there is none", () => {
render(
<MemoryRouter>
<ChatOutline
outline={{
...outline,
progress: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
}}
onClose={() => {}}
onOpenFile={() => {}}
onOpenList={() => {}}
/>
</MemoryRouter>
);
expect(screen.queryByText("Progress")).toBeNull();
expect(screen.queryByText("Used in this session")).toBeNull();
expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy();
});
});
@@ -7,12 +7,13 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -24,8 +25,18 @@ vi.mock("../TerminalView", () => ({
),
}));
vi.mock("../PaneChat", () => ({
PaneChat: ({ sessionId }: { sessionId: string }) => (
<div data-testid="pane-chat" data-session-id={sessionId} />
),
}));
vi.mock("../../../lib/api", () => ({
api: {
me: {
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
savePrefs: vi.fn(),
},
lanes: {
ensure: vi.fn(),
action: vi.fn(),
@@ -139,6 +150,68 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
});
it("shows a Claude pane as a chat OVER its terminal when the viewer's Chat UI is on", async () => {
// Unmounting the terminal would drop the attach the chat sends through.
vi.mocked(api.me.prefs).mockResolvedValueOnce({
prefs: { "agenthub.chatUi": "1" },
onboardedAt: null,
});
__resetChatUi();
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-1",
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
__resetChatUi();
});
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
vi.mocked(api.me.prefs).mockResolvedValueOnce({
prefs: { "agenthub.chatUi": "1" },
onboardedAt: null,
});
__resetChatUi();
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-1",
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
// Seen once, the session keeps chat: turning the default off leaves it alone.
await waitFor(() =>
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
);
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
await act(() => saveChatUi(false));
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
// Its own toggle switches it.
act(() => setSessionChat("s-1", false));
expect(screen.queryByTestId("pane-chat")).toBeNull();
__resetChatUi();
});
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
const run = (id: string, laneId: number) => ({
@@ -0,0 +1,274 @@
/**
* @file PaneChat.test.tsx
* @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins
* → a plugin's skills, each inserted as `/<name>`), the Model/Effort chip typed
* into the pane as `/model` / `/effort`, and the permission mode read off the
* pane and stepped by the server — none of them for a Codex pane; and the
* activity panel, collapsed into a file button with the output count, whose
* files open a preview in its place; a file in a turn's "Edited N files" card
* opens in the Workspace's Changes panel when it is in the pane's folder; and
* Stop in Send's place while the main agent works, pressing Esc in the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat } from "../PaneChat";
import { api } from "../../../lib/api";
import { interruptRun, submitToRun } from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file.
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
<div data-testid="conversation">
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
</div>
),
}));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
agents: { list: r({ agents: [] }) },
aiTools: {
models: r({
flag: true,
models: [
{ id: "opus", label: "Opus" },
{ id: "sonnet", label: "Sonnet" },
],
defaultModel: null,
}),
},
run: {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
permissionMode: r({ mode: "acceptEdits" }),
setPermissionMode: r({ mode: "plan" }),
},
sessions: {
outline: r({
progress: [],
outputs: ["/w/notes.md", "/w/report.xlsx"],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
}),
},
lanes: {
gitFile: r({
available: true,
path: "notes.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Notes", "", "hello"],
}),
gitRawUrl: () => "",
},
ccConfig: {
commands: r({ items: [] }),
skills: r({
items: [{ scope: "user", name: "morning", frontmatter: { description: "Plan the day" } }],
}),
plugins: r({
plugins: [
{
name: "doox",
enabled: true,
contributes: {
items: {
skills: [{ name: "plan-consolidation", description: "Merge plans" }],
commands: [],
},
},
},
{
name: "off",
enabled: false,
contributes: { items: { skills: [{ name: "x" }], commands: [] } },
},
],
}),
},
},
};
});
const RUN = {
id: "agenthub-lane-1",
tool: "claude" as "claude" | "codex",
model: "opus",
effort: "high" as const,
permissionMode: "auto" as const,
};
function renderChat(run: Partial<typeof RUN> = {}) {
return render(
<MemoryRouter>
<PaneChat run={{ ...RUN, ...run }} sessionId="s1" cwd="/w" />
</MemoryRouter>
);
}
beforeEach(() => vi.mocked(submitToRun).mockClear());
describe("PaneChat — the + menu", () => {
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
renderChat();
const box = screen.getByPlaceholderText("Write a message…") as HTMLTextAreaElement;
fireEvent.change(box, { target: { value: "today" } });
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-skills"));
fireEvent.click(await screen.findByTestId("chat-add-skill-morning"));
expect(box.value).toBe("/morning today");
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-plugins"));
expect(screen.queryByTestId("chat-add-plugin-off")).toBeNull(); // disabled plugin
fireEvent.click(await screen.findByTestId("chat-add-plugin-doox"));
fireEvent.click(screen.getByTestId("chat-add-skill-doox:plan-consolidation"));
expect(box.value).toBe("/doox:plan-consolidation /morning today");
});
it("Upload opens the prompt box's file picker", () => {
renderChat();
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
const clicked = vi.spyOn(input, "click");
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-upload"));
expect(clicked).toHaveBeenCalled();
});
});
describe("PaneChat — Stop", () => {
it("replaces Send while the main agent works and the box is empty, and presses Esc", async () => {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "working" }],
} as never);
renderChat();
fireEvent.click(await screen.findByTestId("pane-chat-stop"));
expect(interruptRun).toHaveBeenCalledWith("agenthub-lane-1");
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
expect(screen.getByTestId("pane-chat-send")).toBeInTheDocument();
});
it("drops Stop once the turn ended even when no live update says so", async () => {
// A missed agent_updated (another dashboard on the same database got the
// hook, or the socket dropped): the poll while working catches it.
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "working" }],
} as never);
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
}
});
it("keeps Send for a session at rest", async () => {
// With session_id the server answers EVERY agent of the session (its
// status filter does not apply): the main agent is there, waiting.
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "waiting" }],
} as never);
renderChat();
await waitFor(() => expect(api.agents.list).toHaveBeenCalled());
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
});
});
describe("PaneChat — model, effort and permission mode", () => {
it("types /model into the pane", async () => {
renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
});
it("shows the mode the pane shows, and asks the server to step it", async () => {
renderChat();
const chip = byId("pane-chat-mode");
await waitFor(() => expect(chip()).toHaveTextContent("Accept edits"));
fireEvent.click(chip().querySelector("button")!);
fireEvent.click(await screen.findByRole("option", { name: /Plan/ }));
await waitFor(() =>
expect(api.run.setPermissionMode).toHaveBeenCalledWith("agenthub-lane-1", "plan")
);
await waitFor(() => expect(chip()).toHaveTextContent("Plan"));
});
it("a Codex pane gets the + menu's Upload only — no skills, model or mode", () => {
renderChat({ tool: "codex" });
expect(screen.queryByTestId("pane-chat-mode")).toBeNull();
expect(screen.queryByRole("button", { name: /Opus/ })).toBeNull();
fireEvent.click(screen.getByTestId("chat-add"));
expect(screen.getByTestId("chat-add-upload")).toBeTruthy();
expect(screen.queryByTestId("chat-add-skills")).toBeNull();
});
});
const byId = (id: string) => () => screen.getByTestId(id);
describe("PaneChat — the activity panel", () => {
it("opens from the file button, previews a file, and closes back to the panel", async () => {
localStorage.removeItem("agenthub.chat.outlineOpen");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
</MemoryRouter>
);
expect(screen.queryByTestId("chat-outline")).toBeNull();
const show = screen.getByTestId("chat-outline-show");
await waitFor(() => expect(show.textContent).toBe("2"));
fireEvent.click(show);
fireEvent.click(screen.getByRole("button", { name: "MD: notes" }));
expect(screen.queryByTestId("chat-outline")).toBeNull();
await screen.findByTestId("markdown-preview");
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(7, "notes.md");
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
});
});
describe("PaneChat — an edited file", () => {
it("opens in the Changes panel when it is in the pane's folder, else in its own preview", async () => {
const seen: unknown[] = [];
const listen = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("edited-in"));
expect(seen).toEqual([{ laneId: 7, path: "src/a.ts" }]);
expect(screen.queryByTestId("chat-file-preview")).toBeNull();
// Outside the folder the Changes panel cannot show it: the chat's preview does.
fireEvent.click(screen.getByTestId("edited-out"));
expect(seen).toHaveLength(1);
expect(await screen.findByTestId("chat-file-preview")).toBeInTheDocument();
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
});
});
@@ -3,7 +3,10 @@
* @description The run-form pieces that outlived the pane's run form:
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* pasted image shown as a thumbnail and sent as its saved path (and
* remembered as a recent upload), a Files panel row dropped as an `@path`
* chip, a composer
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
* same list as one chip: aliases up front, pinned versions under More models,
@@ -13,17 +16,19 @@
*/
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import {
ModelMenu,
ModelPicker,
PASTE_AS_FILE_CHARS,
PromptEditor,
promptWithImages,
type PromptImage,
} from "../RunSetup";
import { api } from "../../../lib/api";
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
});
});
describe("PromptEditor — a composer grows with its text", () => {
it("takes the height its text needs, capped by max-h, and only when bare", () => {
const tall = vi
.spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
.mockReturnValue(180);
const props = {
placeholder: "Ask anything…",
slash: false,
fileCwd: "/Users/tester",
images: [],
onImagesChange: () => {},
onChange: () => {},
onSubmit: () => {},
};
const view = render(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
expect(box().style.height).toBe("180px");
expect(box().className).toContain("max-h-[40vh]");
tall.mockReturnValue(60);
view.rerender(<PromptEditor {...props} value="a" bare />);
expect(box().style.height).toBe("60px");
view.unmount();
// The framed editor keeps its fixed rows.
render(<PromptEditor {...props} value={"a\nb\nc"} />);
expect(box().style.height).toBe("");
tall.mockRestore();
});
});
describe("PromptEditor — newlines and images", () => {
it("Alt+Enter inserts a newline and does not send", () => {
const spies = renderEditor("ab");
@@ -186,6 +219,7 @@ describe("PromptEditor — newlines and images", () => {
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
lane={7}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</>
@@ -195,12 +229,14 @@ describe("PromptEditor — newlines and images", () => {
const img = new File(["x"], "shot.png", { type: "image/png" });
fireEvent.paste(box(), { clipboardData: { files: [img] } });
const thumb = await screen.findByRole("button", { name: "shot.png" });
expect(api.run.dropFile).toHaveBeenCalledWith(img);
// Saved where workspace 7's panes can read it.
expect(api.run.dropFile).toHaveBeenCalledWith(img, 7);
// The path is not typed into the prompt; it goes with it on send.
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
fireEvent.click(thumb);
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
// On the page, not in the composer's layer: nothing stacked over a pane covers it.
expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
@@ -210,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("see");
});
it("a composer's Upload opens a picker whose files attach like a paste", async () => {
const pick: { current: (() => void) | null } = { current: null };
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
pickFiles={pick}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
const clicked = vi.spyOn(input, "click");
pick.current?.();
expect(clicked).toHaveBeenCalled();
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
fireEvent.change(input, { target: { files: [doc] } });
expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named
expect(await screen.findByText("spec.pdf")).toBeTruthy();
});
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
function Harness() {
@@ -235,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
expect(await screen.findByText("spec.pdf")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
// Remembered for the + menu's Recent uploads.
expect(recentUploads()).toContainEqual({
path: "/tmp/agenthub-drops-1/b-spec.pdf",
name: "spec.pdf",
});
});
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const data: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (data[k] = v),
getData: (k: string) => data[k] ?? "",
get types() {
return Object.keys(data);
},
files: [],
};
// What a Files panel row's onDragStart puts on the drag.
dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/");
fireEvent.drop(box(), { dataTransfer });
fireEvent.drop(box(), { dataTransfer });
expect(screen.getByText("lib/")).toBeTruthy();
expect(api.run.dropFile).not.toHaveBeenCalled();
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } });
fireEvent.paste(box(), {
clipboardData: { files: [], getData: () => "a short note" },
});
expect(api.run.dropFile).not.toHaveBeenCalled();
});
it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => {
renderEditor("");
const long = "x".repeat(PASTE_AS_FILE_CHARS + 1);
fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } });
await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1));
const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File;
expect(file.name).toBe("pasted-text.txt");
expect(file.type).toBe("text/plain");
expect(file.size).toBe(long.length); // the whole paste, nothing cut
expect(box()).toHaveValue(""); // not typed into the box
});
});
describe("ModelPicker", () => {
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView, loadXterm } from "../TerminalView";
import { pasteToRun } from "../../../lib/panePaste";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
@@ -23,7 +23,7 @@ const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -69,6 +69,7 @@ vi.mock("@xterm/xterm", () => ({
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
@@ -311,6 +312,25 @@ describe("TerminalView", () => {
}
});
it("takes the workspace's paste once it is the focused pane, clicked into or not", () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800);
try {
const both = (focus: number) => (
<>
<TerminalView runId="agenthub-lane-9" wsBaseUrl="ws://x" autoFocus={focus === 0} />
<TerminalView runId="agenthub-lane-9-1" wsBaseUrl="ws://x" autoFocus={focus === 1} />
</>
);
const { rerender } = render(both(0));
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9");
// Alt+2, a tab click: the Workspace moves the focus, the DOM may not.
rerender(both(1));
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9-1");
} finally {
width.mockRestore();
}
});
it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane.
@@ -349,7 +369,9 @@ describe("TerminalView", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 }));
expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
});
it("reports a size change xterm made on its own, with no container resize", async () => {
@@ -366,7 +388,34 @@ describe("TerminalView", () => {
onResizeHandlers.forEach((fn) => fn());
expect(ws.sent).toEqual([]);
await new Promise((r) => setTimeout(r, 200));
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 127, rows: 28 })]);
expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 127, rows: 28, drive: false }),
]);
});
it("follows another browser's size until a keystroke here takes the lead", () => {
// Two browsers of different sizes on one pane: the one not in use renders
// the driver's grid instead of resizing tmux under it.
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
ws.onmessage?.({ data: '{"type":"size","cols":90,"rows":20}' });
expect([termState.cols, termState.rows]).toEqual([90, 20]);
ws.sent.length = 0;
onDataHandlers.forEach((fn) => fn("\x1b[<64;5;5M")); // a wheel notch
expect(ws.sent).toEqual(["\x1b[<64;5;5M"]);
onDataHandlers.forEach((fn) => fn("a"));
expect(ws.sent[1]).toBe(
JSON.stringify({ type: "resize", cols: 90, rows: 20, drive: true })
);
expect(ws.sent[2]).toBe("a");
} finally {
w.mockRestore();
h.mockRestore();
}
});
it("never reports a collapsed console's zero size", () => {
@@ -432,7 +481,8 @@ describe("TerminalView", () => {
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img);
// The pane's run id: saved where that pane can read it.
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
@@ -10,6 +10,8 @@
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default /
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
* A click on an installed row (outside its buttons) opens that tool's settings
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
* working.
@@ -19,12 +21,14 @@
import { Switch } from "../Switch";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { api, type AiTool, type AiToolJob } from "../../lib/api";
import { usePermissions } from "../AuthGate";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
);
}
export function AiToolsSettings() {
export function AiToolsSettings({
settingsHref,
}: {
/** A tool's own settings page, when it has one. */
settingsHref?: (id: string) => string | undefined;
} = {}) {
const { t } = useTranslation("settings");
const navigate = useNavigate();
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
// Installing, updating and switching tools changes the host: an admin's.
const { isAdmin } = usePermissions();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{updateAllTools.length > 0 && (
{isAdmin && updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
{t("aiTools.updateAll", { count: updateAllTools.length })}
</button>
)}
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
{isAdmin && (
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
)}
</div>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
const href = settingsHref?.(tool.id);
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
onContextMenu={(e) => ctx.open(e, tool)}
className="px-3 py-2.5"
onClick={(e) => {
// The row's own buttons, switch, links and log keep their click.
if (href && !(e.target as Element).closest("button, a, input, pre"))
navigate(href);
}}
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
>
<div className="flex items-center gap-3">
<div
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
<AiToolIcon tool={tool} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span>
{href ? (
<Link
to={href}
title={t("aiTools.openSettings", { tool: tool.label })}
className="truncate hover:text-accent hover:underline"
>
{tool.label}
</Link>
) : (
<span className="truncate">{tool.label}</span>
)}
<Homepage tool={tool} />
{isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
</div>
</div>
{/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
{isAdmin && (
<>
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch
tool={tool}
disabled={busy !== null}
onToggle={() => toggle(tool)}
/>
</>
)}
</div>
{logFor(tool.id)}
</li>
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
</ul>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
{ctx.menu.item.update && (
{isAdmin && ctx.menu.item.update && (
<MenuItem
disabled={jobBusy}
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
{t("aiTools.update")}
</MenuItem>
)}
{ctx.menu.item.id !== defaultId && (
{isAdmin && ctx.menu.item.id !== defaultId && (
<MenuItem
disabled={!ctx.menu.item.enabled || busy !== null}
onSelect={pick(setDefault)}
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
{t("aiTools.setDefault")}
</MenuItem>
)}
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
{isAdmin && (
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
)}
{ctx.menu.item.path && (
<>
<MenuSeparator />
{isAdmin && <MenuSeparator />}
<MenuItem
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
>
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
</span>
</span>
<Homepage tool={tool} />
{tool.install && (
{isAdmin && tool.install && (
<button
type="button"
disabled={jobBusy}
@@ -1,6 +1,8 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
* @description Settings → Appearance (`/settings/appearance`): the per-user
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
@@ -8,18 +10,29 @@
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
* **Run setup again** reopens the first-run wizard (`components/onboarding`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings";
import { Select } from "../Select";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { resetOnboarding } from "../../lib/userPrefs";
import { toast } from "../../lib/toast";
import {
TERMINAL_FONTS,
readTerminalFont,
writeTerminalFont,
type TerminalFont,
} from "../../lib/terminalFont";
const THEME_LABELS: Record<string, string> = {
export const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
@@ -34,7 +47,7 @@ const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "system", Icon: Monitor },
];
function Swatch({ mode, name }: { mode: Theme; name: string }) {
export function Swatch({ mode, name }: { mode: Theme; name: string }) {
return (
<div
aria-hidden
@@ -65,6 +78,11 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
export function AppearanceSettings() {
const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const [termFont, setTermFont] = useState(readTerminalFont);
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
setTermFont(font);
};
const reset = async () => {
const ok = await confirmDialog({
@@ -81,6 +99,8 @@ export function AppearanceSettings() {
return (
<div className="space-y-6">
<ChatUiSettings />
<SettingsSection
id="language"
title={t("language.title")}
@@ -102,6 +122,38 @@ export function AppearanceSettings() {
/>
</SettingsSection>
<SettingsSection
id="terminal-font"
title={t("terminalFont.title")}
description={t("terminalFont.description")}
>
<SettingsRow
label={t("terminalFont.label")}
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
control={
// A dropdown: five font names overflow a segmented row.
<Select<TerminalFont>
label={t("terminalFont.title")}
value={termFont}
onChange={pickTermFont}
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
value: f,
label: t(`terminalFont.names.${f}`),
}))}
/>
}
/>
<p
data-testid="terminal-font-preview"
aria-label={t("terminalFont.preview")}
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
</SettingsSection>
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow
@@ -173,6 +225,26 @@ export function AppearanceSettings() {
</div>
<PetSettings />
</section>
<SettingsSection
id="setup"
title={t("onboarding:rerun.label")}
description={t("onboarding:rerun.description")}
>
<SettingsRow
label={t("onboarding:rerun.label")}
control={
<button
type="button"
onClick={resetOnboarding}
data-testid="rerun-setup"
className="btn-ghost border border-border text-xs"
>
{t("onboarding:rerun.button")}
</button>
}
/>
</SettingsSection>
</div>
);
}
@@ -3,15 +3,22 @@
* whether the dashboard starts at boot/login through the per-user service
* (systemd `--user` unit, or a LaunchAgent on macOS), over
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
* stops the running server.
* stops the running server. Beside it, **Quit AgentHub** (issue #65, 9router's
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
* stops the server, which stays stopped until `agenthub start` — or the next
* boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AutostartStatus } from "../../lib/api";
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
import { confirmDialog } from "../../lib/confirm";
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function AutostartSettings() {
const { t } = useTranslation("settings");
@@ -36,6 +43,57 @@ export function AutostartSettings() {
}
};
const [tray, setTray] = useState<TrayStatus | null>(null);
useEffect(() => {
api.settings.tray
.get()
.then(setTray)
.catch(() => {});
}, []);
const changeTray = async (enabled: boolean) => {
try {
setTray(await api.settings.tray.set(enabled));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const [quitting, setQuitting] = useState(false);
const quit = async () => {
const ok = await confirmDialog({
title: t("quit.confirmTitle"),
message: t(status?.enabled ? "quit.confirmMessageAutostart" : "quit.confirmMessage"),
confirmLabel: t("quit.button"),
});
if (!ok) return;
setQuitting(true);
try {
await api.settings.quit();
toast.success(t("quit.done"));
} catch (err) {
setQuitting(false);
toast.error(err instanceof Error ? err.message : String(err));
}
};
const [uninstalling, setUninstalling] = useState(false);
const uninstall = async () => {
const ok = await confirmDialog({
title: t("uninstall.confirmTitle"),
message: t("uninstall.confirmMessage"),
confirmLabel: t("uninstall.button"),
});
if (!ok) return;
setUninstalling(true);
try {
const { log } = await api.settings.uninstall();
toast.success(t("uninstall.done", { log }));
} catch (err) {
setUninstalling(false);
toast.error(err instanceof Error ? err.message : String(err));
}
};
const note = !status
? undefined
: !status.available
@@ -53,6 +111,43 @@ export function AutostartSettings() {
disabled={!status?.available || status.foreign || saving}
onChange={(v) => void change(v)}
/>
<SettingsSwitchRow
label={t("tray.label")}
description={tray && !tray.available ? t("tray.unavailable") : t("tray.desc")}
checked={!!tray?.available && tray.enabled}
disabled={!tray?.available}
onChange={(v) => void changeTray(v)}
/>
<SettingsRow
label={t("quit.label")}
description={t("quit.desc")}
control={
<button
type="button"
data-testid="quit-agenthub"
disabled={quitting}
onClick={() => void quit()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
>
{t("quit.button")}
</button>
}
/>
<SettingsRow
label={t("uninstall.label")}
description={t("uninstall.desc")}
control={
<button
type="button"
data-testid="uninstall-agenthub"
disabled={uninstalling}
onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
>
{t("uninstall.button")}
</button>
}
/>
</SettingsSection>
);
}
@@ -0,0 +1,202 @@
/**
* @file BackupSettings.tsx
* @description Settings → Data → Backup: the daily database backup of
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
* while it runs, then toasts the outcome) and the newest backup in the folder.
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
* field the environment set is read-only. Restoring is manual and the card says
* how.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, DatabaseBackup } from "lucide-react";
import { api } from "../../lib/api";
import type { BackupState } from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { Select } from "../Select";
import { SettingsSection } from "./SettingsRow";
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
export function BackupSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<BackupState | null>(null);
const [dir, setDir] = useState("");
const [hour, setHour] = useState(2);
const [keep, setKeep] = useState("7");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const apply = (s: BackupState) => {
setState(s);
setDir(s.dir);
setHour(s.hour);
setKeep(String(s.keep));
};
useEffect(() => {
api.settings.backup
.get()
.then(apply)
.catch(() => undefined);
}, []);
// A backup takes tens of seconds on a large database: poll until it ends.
const running = !!state?.running;
useEffect(() => {
if (!running) return;
const id = window.setInterval(() => {
api.settings.backup
.get()
.then((s) => {
setState(s); // the form keeps its drafts
if (s.running || !s.last) return;
if (s.last.ok) toast.success(t("backup.done", { file: s.last.file }));
else toast.error(t("backup.failed", { error: s.last.error }));
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [running, t]);
const keepN = Number(keep);
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
const patch: { dir?: string; hour?: number; keep?: number } = {};
if (state) {
if (dir.trim() !== state.dir) patch.dir = dir.trim();
if (hour !== state.hour) patch.hour = hour;
if (keepN !== state.keep) patch.keep = keepN;
}
const changed = Object.keys(patch).length > 0;
async function save() {
setSaving(true);
setError(null);
try {
apply(await api.settings.backup.save(patch));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function runNow() {
try {
setState(await api.settings.backup.run());
} catch (err) {
toast.error(err);
}
}
const owned = state?.env_owned;
const newest = state?.files[0];
const total = state?.files.reduce((n, f) => n + f.size, 0) ?? 0;
return (
<SettingsSection
id="backup"
title={
<span className="flex items-center gap-2">
<DatabaseBackup className="w-4 h-4 text-fg-muted" />
{t("backup.title")}
</span>
}
description={t("backup.description")}
>
<div className="py-3 space-y-3">
<label className="block text-xs text-fg-muted">
{t("backup.folder")}
<input
className="input mt-1 w-full font-mono text-xs"
placeholder="/mnt/hdd/agenthub-backups"
value={dir}
disabled={!state || owned?.dir}
onChange={(e) => {
setDir(e.target.value);
setError(null);
}}
/>
</label>
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="w-28">
<Select
value={String(hour)}
onChange={(v) => setHour(Number(v))}
label={t("backup.hour")}
disabled={!state || owned?.hour}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
<label className="text-xs text-fg-muted">
{t("backup.keep")}
<input
type="number"
min={1}
max={365}
step={1}
className="input mt-1 block w-24 text-sm"
value={keep}
disabled={!state || owned?.keep}
onChange={(e) => setKeep(e.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || !keepValid}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
<button
type="button"
className="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!state?.dir || running || changed}
onClick={() => void runNow()}
>
<DatabaseBackup className={`w-4 h-4 ${running ? "animate-pulse" : ""}`} />{" "}
{running ? t("backup.running") : t("backup.runNow")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
<p className="text-xs text-fg-secondary">
{newest
? t("backup.newest", {
time: formatDateTime(newest.mtime),
size: formatBytes(newest.size),
n: state?.files.length,
total: formatBytes(total),
})
: state?.dir
? t("backup.none")
: t("backup.off")}
</p>
{state?.last && !state.last.ok && (
<p className="text-xs text-status-danger">
{t("backup.lastFailed", {
time: formatDateTime(state.last.at),
error: state.last.error,
})}
</p>
)}
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
{(owned?.dir || owned?.hour || owned?.keep) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
</div>
</SettingsSection>
);
}
@@ -0,0 +1,33 @@
/**
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
* account on a shared dashboard keeps its own choice on any browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { saveChatUi, useChatUi } from "../../lib/chatUi";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChatUiSettings() {
const { t } = useTranslation("settings");
const chatUi = useChatUi();
return (
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
<SettingsSwitchRow
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
)
)
}
/>
</SettingsSection>
);
}
@@ -3,8 +3,9 @@
* @description The dashboard's own settings that belong to Claude Code, shown
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
* plugin provides them), and a link to the Claude table on the Pricing page.
* Changing CLAUDE_HOME asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,12 +15,17 @@ import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from
import { api } from "../../lib/api";
import { Skeleton } from "../Skeleton";
import { HomeDirSetting } from "./HomeDirSetting";
import { PricingSettings } from "./PricingSettings";
import { PricingLink } from "./PricingSettings";
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
type HooksInfo = {
installed: boolean;
path: string;
hooks: Record<string, boolean>;
source?: "plugin";
};
/**
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
* Claude Code's AgentHub settings: CLAUDE_HOME, hook status and the pricing link.
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
* the config it lists, which lives under that home).
* @returns The three sections, each with its own id.
@@ -205,13 +211,17 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
</div>
))}
</div>
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
{hooks.source === "plugin" ? (
<p className="text-[11px] text-fg-muted">{t("hooks.plugin")}</p>
) : (
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
)}
</>
)}
</div>
</section>
<PricingSettings vendor="claude" allowReset />
<PricingLink vendor="claude" />
</div>
);
}
+47 -39
View File
@@ -33,6 +33,7 @@ import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
import { usePermissions } from "../AuthGate";
const PREVIEW = 72;
@@ -121,6 +122,8 @@ export function PetSettings() {
const { t } = useTranslation("settings");
const worn = useWornPet();
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Pets are stored for the whole install: adding or deleting one is an admin's.
const { isAdmin } = usePermissions();
const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
@@ -219,6 +222,7 @@ export function PetSettings() {
/>
{list?.featured.map((f) => {
const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return (
<PetCard
key={f.id}
@@ -252,50 +256,54 @@ export function PetSettings() {
disabled={busy !== null}
preview={<Preview pet={p} size={PREVIEW} />}
onPick={() => wear(p)}
onDelete={() => void remove(p)}
onDelete={isAdmin ? () => void remove(p) : undefined}
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
/>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
{isAdmin && (
<>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
</>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
@@ -19,7 +19,18 @@
import { Checkbox } from "../Checkbox";
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
import { Link } from "react-router-dom";
import {
ArrowRight,
DollarSign,
Plus,
Pencil,
Trash2,
Check,
X,
RotateCcw,
Info,
} from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
@@ -191,6 +202,29 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
export type PricingVendor = "claude" | "openai" | "google";
/**
* A tool page's pointer to its vendor's table on the one Pricing page — the
* table itself lives only there, so a price is never shown (or edited) twice.
*/
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
const { t } = useTranslation("settings");
return (
<div className="card flex items-center justify-between gap-4 p-5">
<div className="min-w-0">
<h3 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<DollarSign className="h-4 w-4 text-fg-muted" />
{t("pricingLink.title")}
</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
</div>
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
{t("pricingLink.open")}
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
);
}
// Every mounted table's `load`: a change in one reloads them all, and loads
// fired together share one request.
const tables = new Set<() => void>();
@@ -1,108 +0,0 @@
/**
* @file UsersSettings.tsx
* @description Settings → Users, server mode only and only for an admin or
* analyst (docs/SERVER-MODE.md): every account with its role, state,
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
* (`GET /api/users`), and the audit log of staff reads of members' sessions
* (`GET /api/users/audit`). Read-only — accounts are managed with
* `agenthub users …` on the host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { SettingsSection } from "./SettingsRow";
export function UsersSettings() {
const { t } = useTranslation("settings");
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch((e: Error) => setError(e.message));
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([]));
}, []);
return (
<>
<SettingsSection title={t("users.title")} description={t("users.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{u.disabled && (
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
)}
</td>
<td className="py-2 pr-3">{u.role}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
</SettingsSection>
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
{audit?.length === 0 && (
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
)}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
</>
);
}
@@ -5,7 +5,8 @@
* a homepage link, Refresh re-detects, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done. An installed row's right-click menu runs
* the same Set default / enable switch as its controls.
* the same Set default / enable switch as its controls. A click on a row (or
* its name link) opens the tool's settings page; its buttons do not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,6 +14,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { AiToolsSettings } from "../AiToolsSettings";
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -92,9 +94,48 @@ const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
...over,
});
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname}</span>;
}
describe("AiToolsSettings", () => {
it("opens a tool's settings from its row or name, never from its buttons", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/settings/agents"]}>
<Routes>
<Route
path="*"
element={
<>
<AiToolsSettings
settingsHref={(id) => (id === "gemini" ? `/settings/agents/${id}` : undefined)}
/>
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByText("Set default"));
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
await user.click(gemini);
expect(screen.getByTestId("where").textContent).toBe("/settings/agents/gemini");
expect(within(gemini).getByRole("link", { name: "Gemini" })).toHaveAttribute(
"href",
"/settings/agents/gemini"
);
// A tool without a page has no link.
expect(
within(screen.getByTestId("ai-tool-claude")).queryByRole("link", { name: "Claude Code" })
).toBeNull();
});
it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
const claude = screen.getByTestId("ai-tool-claude");
@@ -107,7 +148,7 @@ describe("AiToolsSettings", () => {
it("toggles a tool and sets the default through the API", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
@@ -121,7 +162,7 @@ describe("AiToolsSettings", () => {
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude");
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
@@ -129,7 +170,7 @@ describe("AiToolsSettings", () => {
it("sets the default and disables a tool from its right-click menu", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.pointer({ keys: "[MouseRight]", target: gemini });
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
@@ -182,7 +223,7 @@ describe("AiToolsSettings — install and update", () => {
vi.mocked(api.aiTools.job)
.mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" }));
@@ -212,7 +253,7 @@ describe("AiToolsSettings — install and update", () => {
it("names the vendor script for a script install", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable();
const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" }));
@@ -222,7 +263,7 @@ describe("AiToolsSettings — install and update", () => {
});
it("offers Update on every installed tool with an updater", async () => {
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
for (const id of ["claude", "codex", "gemini"]) {
const row = await screen.findByTestId(`ai-tool-${id}`);
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
@@ -231,7 +272,7 @@ describe("AiToolsSettings — install and update", () => {
it("Update all lists every installed tool's updater", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update");
@@ -241,13 +282,13 @@ describe("AiToolsSettings — install and update", () => {
it("hides Update all when no tool has an updater", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude");
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
const { container } = render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable();
await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
@@ -1,6 +1,6 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -73,8 +73,8 @@ describe("AppearanceSettings", () => {
it("the System mode is checked after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("radio", { name: /System/ }));
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
await user.click(screen.getByRole("radio", { name: "System" }));
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
@@ -84,4 +84,22 @@ describe("AppearanceSettings", () => {
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
});
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
const trigger = screen.getByRole("button", { name: /Terminal font/ });
expect(trigger).toHaveTextContent("JetBrains Mono");
const pick = async (name: string) => {
await user.click(screen.getByRole("button", { name: /Terminal font/ }));
await user.click(screen.getByRole("option", { name }));
};
await pick("Maple Mono (rounded)");
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
await pick("System font");
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
});
});
@@ -0,0 +1,96 @@
/**
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
* first, says when start-at-login will bring the dashboard back, and only a
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
* switch, which goes straight to PUT /api/settings/tray, and Uninstall
* AgentHub, which also asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AutostartSettings } from "../AutostartSettings";
import { api } from "../../../lib/api";
import * as confirm from "../../../lib/confirm";
vi.mock("../../../lib/api", () => ({
api: {
settings: {
autostart: { get: vi.fn(), set: vi.fn() },
tray: { get: vi.fn(), set: vi.fn() },
quit: vi.fn(),
uninstall: vi.fn(),
},
},
}));
const status = { available: true, kind: "systemd", enabled: true, foreign: false };
const trayOff = { available: true, enabled: false, running: false };
describe("Quit AgentHub", () => {
beforeEach(() => {
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
vi.mocked(api.settings.quit).mockReset().mockResolvedValue({ ok: true, killed: 0 });
});
it("does nothing when the ask is cancelled", async () => {
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("quit-agenthub"));
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
expect(api.settings.quit).not.toHaveBeenCalled();
});
it("quits once confirmed, warning that start-at-login brings it back", async () => {
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
render(<AutostartSettings />);
await waitFor(() => expect(api.settings.autostart.get).toHaveBeenCalled());
await screen.findByRole("switch", { checked: true });
fireEvent.click(screen.getByTestId("quit-agenthub"));
await waitFor(() => expect(api.settings.quit).toHaveBeenCalledTimes(1));
expect(ask.mock.calls[0]![0].message).toMatch(/next boot\/login/);
expect(screen.getByTestId("quit-agenthub")).toBeDisabled();
});
it("turns the status-area icon on from its switch", async () => {
vi.mocked(api.settings.tray.set).mockResolvedValue({
...trayOff,
enabled: true,
running: true,
});
render(<AutostartSettings />);
await waitFor(() => expect(api.settings.tray.get).toHaveBeenCalled());
const icon = screen.getAllByRole("switch")[1]!;
await waitFor(() => expect(icon).toBeEnabled());
fireEvent.click(icon);
await waitFor(() => expect(api.settings.tray.set).toHaveBeenCalledWith(true));
await waitFor(() => expect(icon).toBeChecked());
});
});
describe("Uninstall AgentHub", () => {
beforeEach(() => {
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
vi.mocked(api.settings.uninstall)
.mockReset()
.mockResolvedValue({ ok: true, log: "/tmp/agenthub-uninstall-1.log" });
});
it("does nothing when the ask is cancelled", async () => {
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
expect(api.settings.uninstall).not.toHaveBeenCalled();
});
it("uninstalls once confirmed, saying the database goes too", async () => {
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(api.settings.uninstall).toHaveBeenCalledTimes(1));
expect(ask.mock.calls[0]![0].message).toMatch(/database/);
expect(screen.getByTestId("uninstall-agenthub")).toBeDisabled();
});
});
@@ -0,0 +1,65 @@
/**
* @file BackupSettings.test.tsx
* @description Settings → Data → Backup: a save sends only the fields that
* changed, "Back up now" is off until a folder is saved, and the newest backup
* in the folder is shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { backup } = vi.hoisted(() => ({
backup: { get: vi.fn(), save: vi.fn(), run: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({ api: { settings: { backup } } }));
import { BackupSettings } from "../BackupSettings";
const off = {
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("BackupSettings", () => {
it("saves only the folder when only the folder changed, then allows a run", async () => {
const user = userEvent.setup();
backup.get.mockResolvedValue(off);
backup.save.mockImplementation((p) => Promise.resolve({ ok: true, ...off, ...p }));
render(<BackupSettings />);
const run = await screen.findByRole("button", { name: /Back up now/ });
expect(run).toBeDisabled();
expect(screen.getByText("Backups are off.")).toBeInTheDocument();
await user.type(screen.getByLabelText("Backup folder"), "/mnt/hdd/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ dir: "/mnt/hdd/b" }));
await waitFor(() => expect(run).toBeEnabled());
});
it("shows the newest backup", async () => {
backup.get.mockResolvedValue({
...off,
dir: "/mnt/hdd/b",
files: [
{ name: "agenthub-20261008-020000.db", size: 2048, mtime: "2026-10-08T02:00:30Z" },
{ name: "agenthub-20261007-020000.db", size: 1024, mtime: "2026-10-07T02:00:30Z" },
],
});
render(<BackupSettings />);
expect(await screen.findByText(/2\.0 KB · 2 kept, 3\.0 KB in all/)).toBeInTheDocument();
});
});
@@ -1,20 +1,24 @@
/**
* @file ClaudeAgentHubSettings.test.tsx
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
* Settings → Dashboard (#40 part B) — still load and act as they did there.
* @description Settings → Claude Code → AgentHub: the hook status with
* Reinstall (named as the plugin's when the plugin provides the hooks),
* CLAUDE_HOME, and a link to the Claude table on the Pricing page — the
* table itself is shown only there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor, within } from "@testing-library/react";
import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
const render = (ui: ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
function confirmYes() {
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
@@ -36,24 +40,9 @@ vi.mock("../../../lib/api", async (importOriginal) => {
};
});
const RULE = {
model_pattern: "claude-opus-5-5%",
display_name: "Claude Opus 5.5",
input_per_mtok: 4,
output_per_mtok: 20,
cache_read_per_mtok: 0.2,
cache_write_per_mtok: 5,
cache_write_1h_per_mtok: 8,
fast_input_per_mtok: 8,
fast_output_per_mtok: 40,
updated_at: "2026-09-28 10:00:00",
} as ModelPricing;
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(api.pricing.list)
.mockReset()
.mockResolvedValue({ pricing: [RULE] });
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
vi.mocked(api.settings.info)
.mockReset()
.mockResolvedValue({
@@ -76,10 +65,15 @@ beforeEach(() => {
});
describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
it("renders hook status and CLAUDE_HOME, and links to the Claude prices", async () => {
const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
"href",
"/settings/pricing#pricing-claude"
);
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
expect(api.pricing.list).not.toHaveBeenCalled();
for (const id of ["hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
const hooks = container.querySelector("section#hooks") as HTMLElement;
@@ -121,14 +115,19 @@ describe("ClaudeAgentHubSettings", () => {
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
it("resets pricing only on the second click", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
vi.mocked(api.settings.info).mockResolvedValue({
hooks: {
installed: true,
source: "plugin",
path: "/home/u/.claude/settings.json",
hooks: { UserPromptSubmit: true },
},
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
render(<ClaudeAgentHubSettings />);
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
const note = await screen.findByText(/provided by the agenthub plugin/i);
const hooks = note.closest("section#hooks") as HTMLElement;
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
});
});
+23 -1
View File
@@ -6,14 +6,18 @@
* refetches), and a WebSocket reconnect refetches the whole list because the
* frames missed while disconnected are never replayed. The copy is dropped
* when the last component unmounts, so a fresh mount always reads fresh rows.
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
* work in (server mode: an analyst's own), for the Workspace page; the sidebar
* tree lists them all, other users' read only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import type { Lane, WSMessage } from "../lib/types";
import { useAuth } from "../components/AuthGate";
let lanes: Lane[] | null = null;
// Bumped when the store is torn down, so a reply to a request made before the
@@ -92,3 +96,21 @@ export function useLanes() {
}, []);
return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load };
}
/**
* The workspaces this user WORKS in — the Workspace page.
* Server mode: an analyst gets every workspace from the API (to read) but may
* change only their own, so only those are listed here; other users' work is
* read in the sidebar tree (read only), and on Sessions, Analytics and Users.
*/
export function useWorkLanes() {
const all = useLanes();
const { state } = useAuth();
const analystId =
state.mode === "server" && state.user?.role === "analyst" ? state.user.id : null;
const lanes = useMemo(
() => (analystId == null ? all.lanes : all.lanes.filter((l) => l.owner_id === analystId)),
[all.lanes, analystId]
);
return { ...all, lanes };
}
+25 -3
View File
@@ -7,11 +7,15 @@
* themselves are CSS-variable blocks in `index.css`. State is never cached in
* the module — every instance reads storage + `matchMedia` through
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
* another tab's change arrives through the `storage` event.
* another tab's change arrives through the `storage` event. Every change is also
* saved to the account (`/api/me/prefs`, same keys); a sign-in loads it back
* through `lib/userPrefs.ts` (`hydrate`), while the login page wears the OS's
* light/dark (`applySystemTheme`) because nobody has chosen yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
import { api } from "../lib/api";
export type Theme = "dark" | "light";
export type ThemeMode = Theme | "system";
@@ -19,7 +23,7 @@ export type ThemeMode = Theme | "system";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "apple-light", "canvas", "orca-light"],
light: ["default", "canvas", "apple-light", "orca-light"],
} as const satisfies Record<Theme, readonly string[]>;
const MODE_KEY = "theme";
@@ -35,13 +39,20 @@ function read(key: string): string | null {
}
}
function write(key: string, value: string): void {
function store(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* ignore quota / disabled storage */
}
}
/** A user's own choice: this browser, every reader, and the account. */
function write(key: string, value: string): void {
store(key, value);
window.dispatchEvent(new Event(CHANGE_EVENT));
// This browser keeps it even when the save fails (offline, signed out).
api.me.savePrefs({ [key]: value }).catch(() => {});
}
function systemQuery(): MediaQueryList | undefined {
@@ -86,6 +97,17 @@ function apply(resolved: Theme, name: string): void {
else root.dataset.theme = name;
}
/** Paint what this browser stores (index.html's job at page load). */
export function applyStoredTheme(): void {
const [, resolved, dark, light] = getSnapshot().split("|") as [ThemeMode, Theme, string, string];
apply(resolved, resolved === "dark" ? dark : light);
}
/** The login page: the OS's light/dark in the stock palette. */
export function applySystemTheme(): void {
apply(systemQuery()?.matches === false ? "light" : "dark", "default");
}
/**
* Read/write the dashboard's appearance.
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
+1 -2
View File
@@ -23,8 +23,7 @@ describe("i18n resources", () => {
await i18n.changeLanguage("vi");
expect(i18n.t("nav:dashboard")).toBe("Tổng quan");
expect(i18n.t("nav:agentBoard")).toBe("Bảng Kanban");
expect(i18n.t("nav:languageShort.vi")).toBe("VI");
expect(i18n.t("nav:settings")).toBe("Cài đặt");
});
it("should keep Agent terminology untranslated in the vi locale", async () => {

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