Compare commits

...

11 Commits

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:20:07 +07:00
nntrivi2001 b3fe10a4f3 fix: v2.7.4 — a developer's System page carries no host or other-user path
- GET /api/settings/info for a developer: no DB path, no hooks file path,
  and no transcript-cache keys — those were EVERY user's transcript paths
  (their folders and session ids). GET /api/updates/status: no repo_root.
  Test: server-mode-leaks.
- Also in this release (earlier commits): a member's missing workspace
  folder is made as them at start; --allow-dangerously-skip-permissions only
  for an account that accepted the bypass warning or a bypass run.
Full suites: server 2889/2889, client 1250/1250, tsc -b.
2026-10-12 15:56:26 +07:00
nntrivi2001 8ecb96e9fc fix: a new account's first Claude session no longer opens on the bypass warning
--allow-dangerously-skip-permissions (bypass in the pane's shift+tab cycle)
made Claude Code show its Bypass Permissions warning on any account that had
not accepted it yet — every new member's first session, which the Chat UI
cannot answer, though nobody asked for bypass. Now passed only when the run
starts in bypassPermissions (asking is consent to see it) or the account's
user settings already hold skipDangerousModePermissionPrompt (pty-run
bypassAccepted; a member's read owner-checked from their home). The host,
which accepted long ago, keeps the flag. Test: pty-run.
2026-10-12 15:56:16 +07:00
nntrivi2001 c47dd230d4 fix(s3): starting a member's workspace makes its missing folder, as the member
v2.7.3's ENOCWD left a new member no web path to a first project: their
home is no workspace (EFOLDER) and a not-yet-existing folder could not
start. run-as.makeMemberFolder: mkdir -p through write-as, AS the member,
only inside their own home (never the home itself, never a host folder,
never for a member with no account). Test: server-s3-lanes.
2026-10-12 15:56:16 +07:00
nntrivi2001 46e3899945 fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again
  (a staged change since undone on disk) is no longer listed — JetBrains'
  GitChangesCollector drops it the same way. Mode-only changes stay listed.
  statusCounts (the dirty-tree safety checks) is unchanged.
- Chat (#73): Claude Code runs no hook before a folder is trusted, so a new
  pane's session has no row; the chat now says the session has not started,
  offers Open terminal, and asks before typing a message or /model //effort
  into the pane (an Enter would pick the trust prompt's "No, exit").
- Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on
  each key (insertReplacementText), which xterm.js ignores — only each word's
  first letter reached the pane. The edit is now sent as DELs + the new tail.
- Also: Ctrl+V pastes a screenshot into the terminal as a file path; the
  folder browser fallback lists files for a file field (`files=1`); the macOS
  native picker opens in front of the browser; a hidden tab is marked while a
  chat waits or once a turn ended; chat live line shows running calls, turn
  time and tokens, and "Done in …".
2026-10-12 15:40:51 +07:00
nntrivi2001 5d8fc9bb98 docs+test: the young-run window in SERVER-MODE/LANES; run.test plants its link in the real drop dir
run.test.js hardcoded /tmp/agenthub-drops-<uid>, which drops left for
<data dir>/drops long ago — it passed only while an old build's folder
survived in /tmp (gone after a reboot). Full suites: server 2887/2887,
client 1250/1250.
2026-10-12 14:36:22 +07:00
nntrivi2001 c8ea18f594 fix(client): a developer's Sessions page no longer asks for remote sources (403)
/api/remote-sources is STAFF_ONLY; Sessions loaded its labels for every
viewer, so each developer page load logged a 403.
2026-10-12 14:19:42 +07:00
nntrivi2001 64173a5428 fix: a run started a moment ago is never judged dead — another dashboard's tmux list may predate it
Only the process that starts a pane writes it into its own cached tmux list
(noteSession); a second dashboard on the same database (a dev server) holds
a member list up to 15 s old, so its heal beat ended a Codex pane seconds
after it started and the proxy gateway answered 401. reconcileOrphans and
healRunId skip a run younger than 20 s (dashboard-runs.isYoungRun).
Tests: dashboard-runs (young vs old).
2026-10-12 14:15:22 +07:00
nntrivi2001 943ba03b76 fix: v2.7.3 — Update now restarts through our own service unit, member panes stay live on a dev server, a start refuses a missing folder
- self-restart: a server in our unit's cgroup (/agenthub.service, system
  or user) restarts via `systemctl restart --no-block` (sudo -n for the
  system unit), our launchd job by exiting into KeepAlive. The detached
  restart-helper replacement held the port while Restart=always started
  the unit every 3 s (v2.7.2 on the S3 box: 240+ restarts, MainPID 0).
- run-as.mayHaveTmux: only ENOENT means "no tmux". A dev server in server
  mode without CAP_DAC_READ_SEARCH got EACCES on a member's 0700
  .agenthub/tmux, dropped the member from the list and ended their runs
  within seconds — the real cause of `401 not a live pane's token`.
- tmux: a member's list is re-asked every 5 s, not 1 s (one sudo + three
  auth.log lines each).
- POST /api/lanes/:id/start: 409 ENOCWD for a folder that no longer
  exists (the pane started in the account's home otherwise).
- Settings: Remote sources only for staff (a developer got a 403).
- scripts/test-server.js drops every inherited AGENTHUB_* (pane env).
Tests: self-restart.test.js, server-s3-panes (mayHaveTmux), lanes-api
(ENOCWD); touched suites 771 + client pages 191, tsc -b.
2026-10-12 14:04:08 +07:00
75 changed files with 2949 additions and 374 deletions
+1
View File
@@ -25,6 +25,7 @@ make while you work here as Codex.
## Validation expectations ## Validation expectations
- Backend changes: run `npm run test:server` when possible. - Backend changes: run `npm run test:server` when possible.
- Frontend changes: run `npm run test:client` when possible. - Frontend changes: run `npm run test:client` when possible.
- Full suites take 5–6 minutes, so while iterating run only what the change touches: `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Run the full suite once before committing, and always when the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib) — the per-screen snapshots catch that.
- If any check is skipped, report it explicitly. - If any check is skipped, report it explicitly.
## Safety expectations ## Safety expectations
+1 -1
View File
@@ -2323,7 +2323,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button. - **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved. - **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor. What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
--- ---
+3 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.7.2 (2026-10-12) — an account's own agent instructions go before every workspace's (#67); Chat ⇄ terminal keeps the unsent prompt; a server without S3 never ends a member's run._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. _Last synced: v2.7.5 (2026-10-13) — the chat shows each AskUserQuestion with the user's answer; while iterating, run only the tests a change touches._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -9,7 +9,7 @@ _Last synced: v2.7.2 (2026-10-12) — an account's own agent instructions go bef
## Repo map ## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions, no tokens)). - `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions, no tokens)).
- `client/`: React + Vite UI. - `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.) - `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap. - `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery. - `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`. - `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
@@ -96,6 +96,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
## Testing and verification policy ## Testing and verification policy
- Backend changes: run `npm run test:server` before finishing. - Backend changes: run `npm run test:server` before finishing.
- Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass. - Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass.
- Full suites take 5–6 minutes — while iterating run only what the change touches (`npm run test:server -- server/__tests__/<name>.test.js`; in `client/` `NODE_ENV=test npx vitest related <changed files> --run`), then the full suite once before committing, and always when touching something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib).
- If you cannot run a verification step, state exactly what was not run and why. - If you cannot run a verification step, state exactly what was not run and why.
## Change guidelines by area ## Change guidelines by area
+8 -16
View File
@@ -6,9 +6,9 @@
* user chose. A browser's picker cannot give a page that path, so the dialog * user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the * opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server * browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app * with no desktop or no zenity/kdialog) it falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing * `FolderBrowseModal` (as it does on any other failure) — for a file field
* the path. * too, which then lists files and picks one on click.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -16,12 +16,8 @@ import { useState, type Ref } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal"; import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({ export function PathPickButton({
kind = "dir", kind = "dir",
start, start,
@@ -47,15 +43,10 @@ export function PathPickButton({
try { try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined); const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path); if (r.path) onPick(r.path);
} catch (err) { } catch {
const code = (err as { code?: string }).code; // The in-app browser is the fallback whatever went wrong with the
// A folder always has the in-app browser to fall back on, whatever went // native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
// wrong with the native one (a crashed dialog, one already open). setFallback(true);
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -80,6 +71,7 @@ export function PathPickButton({
<div className="contents" onClick={(e) => e.stopPropagation()}> <div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal <FolderBrowseModal
open={fallback} open={fallback}
kind={kind}
initialPath={start?.trim() || undefined} initialPath={start?.trim() || undefined}
onSelect={onPick} onSelect={onPick}
onClose={() => setFallback(false)} onClose={() => setFallback(false)}
+5 -84
View File
@@ -1,7 +1,7 @@
/** /**
* @file SplashScreen.tsx * @file SplashScreen.tsx
* @description Branding splash shown once per browser session on app load. A * @description Branding splash shown once per browser session on app load. A
* dark-tech "constellation" overlay built around the node-graph brand mark: * dark-tech "constellation" overlay built around the SmartGift brand mark:
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal * a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
* in a staggered cascade. The tagline and the subtext pair are picked at * in a staggered cascade. The tagline and the subtext pair are picked at
* random (per mount) from localized pools in `splash.json`, so the copy is * random (per mount) from localized pools in `splash.json`, so the copy is
@@ -143,7 +143,7 @@ export function SplashScreen() {
<div className="splash-grain" aria-hidden="true" /> <div className="splash-grain" aria-hidden="true" />
<div className="splash-content"> <div className="splash-content">
{/* Brand mark - the node-graph hexagon, enlarged and animated */} {/* Brand mark - the SmartGift logo */}
<div className="splash-mark" aria-hidden="true"> <div className="splash-mark" aria-hidden="true">
<span className="splash-mark-glow" /> <span className="splash-mark-glow" />
<BrandMark /> <BrandMark />
@@ -167,74 +167,10 @@ export function SplashScreen() {
); );
} }
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up /** The SmartGift mark — the same file as the favicon, local so the splash
* with animated connector lines and pulsing outer nodes. */ * needs no network. */
function BrandMark() { function BrandMark() {
return ( return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
<defs>
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#818cf8" />
</linearGradient>
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
</linearGradient>
</defs>
<polygon
className="splash-hex"
points="16,2 28,9 28,23 16,30 4,23 4,9"
fill="url(#splashBg)"
/>
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
<line
className="splash-line"
x1="16"
y1="13"
x2="16"
y2="7"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-2"
x1="18.6"
y1="17.5"
x2="24"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-3"
x1="13.4"
y1="17.5"
x2="8"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
<circle
className="splash-node splash-node-2"
cx="24.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
<circle
className="splash-node splash-node-3"
cx="7.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
</svg>
);
} }
/** Faint background constellation: a handful of nodes joined by thin lines that /** Faint background constellation: a handful of nodes joined by thin lines that
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
animation: splashGlowPulse 2.6s ease-in-out infinite; animation: splashGlowPulse 2.6s ease-in-out infinite;
} }
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); } .splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
.splash-line-2 { animation-delay: 0.62s; }
.splash-line-3 { animation-delay: 0.74s; }
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
.splash-greeting { .splash-greeting {
display: flex; display: flex;
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } } @keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } } @keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } } @keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } } @keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
.splash-star, .splash-star,
.splash-mark, .splash-mark,
.splash-mark-glow, .splash-mark-glow,
.splash-hex,
.splash-line,
.splash-node,
.splash-dot, .splash-dot,
.splash-greeting, .splash-greeting,
.splash-tagline, .splash-tagline,
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
opacity: 1 !important; opacity: 1 !important;
stroke-dashoffset: 0 !important;
transform: none !important; transform: none !important;
} }
} }
@@ -1,14 +1,14 @@
/** /**
* @file PathPickButton.test.tsx * @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen * @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the * path fills the field, a Cancel changes nothing, and a folder or a file falls
* in-app browser on ANY native-picker failure, and a file falls back to a * back to the in-app browser on ANY native-picker failure (a LAN browser's
* "type the path" hint only when no picker exists for this browser. * EREMOTE included).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
expect(toast.error).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled();
}); });
it("hints to type a file path when no picker exists, and reports any other failure", async () => { it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG")); const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG")); vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
render(<PathPickButton kind="file" onPick={vi.fn()} />); vi.mocked(api.lanes.browse).mockResolvedValue({
const button = screen.getByRole("button"); path: "/home/u/.ssh",
await userEvent.click(button); parent: "/home/u",
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1)); entries: [
await userEvent.click(button); { name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG")); ],
});
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
await userEvent.click(screen.getByRole("button"));
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true);
expect(within(dialog).queryByText("Select this folder")).toBeNull();
await userEvent.click(await within(dialog).findByText("id_ed25519"));
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
expect(toast.info).not.toHaveBeenCalled();
}); });
}); });
@@ -9,7 +9,11 @@
* the reader is there: anything that makes it taller — a new line, the live * 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 * 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 * (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
* reader scrolls up; `follow` (a chat's send) sends it back to the end. * reader scrolls up; `follow` (a chat's send) sends it back to the end. A
* chat passes in whether the CLI waits on the user (`waiting`, with
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -62,7 +66,7 @@ import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents"; import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList"; import { MessageList, promptTokens } from "./MessageList";
import { useSpin } from "../../hooks/useSpin"; import { useSpin } from "../../hooks/useSpin";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types"; import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -89,6 +93,17 @@ interface ConversationViewProps {
live?: boolean; live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */ /** Changed (a chat's send): back to the end, and stick to it. */
follow?: number; follow?: number;
/** The CLI is asking the user something (a permission prompt). */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
stopped?: number;
/** The newest reply's prompt size (input + cache) and model: how full the
* context is. */
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
} }
export function ConversationView({ export function ConversationView({
@@ -98,9 +113,27 @@ export function ConversationView({
onOpenFile, onOpenFile,
live = false, live = false,
follow = 0, follow = 0,
waiting = false,
onOpenTerminal,
stopped = 0,
onContext,
onNotStarted,
}: ConversationViewProps) { }: ConversationViewProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]); const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false);
@@ -178,6 +211,7 @@ export function ConversationView({
limit: 50, limit: 50,
}); });
if (cancelled) return; if (cancelled) return;
setNotStarted(false);
setMessages(result.messages); setMessages(result.messages);
setTotal(result.total); setTotal(result.total);
setHasMore(result.has_more); setHasMore(result.has_more);
@@ -189,6 +223,7 @@ export function ConversationView({
// to show yet, and the poll below picks it up once it exists. // to show yet, and the poll below picks it up once it exists.
const notYet = live && (err as { code?: string }).code === "NOT_FOUND"; const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad")); if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
setNotStarted(notYet);
setMessages([]); setMessages([]);
setTotal(0); setTotal(0);
} finally { } finally {
@@ -228,6 +263,7 @@ export function ConversationView({
limit: 50, limit: 50,
}); });
if (gen !== genRef.current) return; // another transcript is shown now if (gen !== genRef.current) return; // another transcript is shown now
setNotStarted(false);
if (result.messages.length === 0) return; if (result.messages.length === 0) return;
lastLineRef.current = result.last_line; lastLineRef.current = result.last_line;
@@ -494,7 +530,9 @@ export function ConversationView({
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]" className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }} style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
> >
<div ref={contentRef}> {/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
{/* History loading indicator */} {/* History loading indicator */}
{loadingHistory && ( {loadingHistory && (
<div className="flex justify-center py-3"> <div className="flex justify-center py-3">
@@ -516,6 +554,22 @@ export function ConversationView({
<div className="flex items-center justify-center py-12 text-fg-muted text-sm"> <div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")} {t("conversation.loading")}
</div> </div>
) : messages.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.notStartedBody")}
</p>
{onOpenTerminal && (
<button
type="button"
onClick={onOpenTerminal}
className="btn-secondary mt-3 text-xs"
>
{t("conversation.openTerminal")}
</button>
)}
</div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center"> <div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p> <p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
@@ -524,7 +578,15 @@ export function ConversationView({
</p> </p>
</div> </div>
) : ( ) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} /> <MessageList
messages={messages}
loading={false}
onOpenFile={onOpenFile}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
/>
)} )}
</div> </div>
</div> </div>
@@ -6,7 +6,8 @@
* MarkdownContent for prose. Used by ConversationView as the main body of * MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page. An image the prompt box * 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 * 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 instead (`GET /api/run/drop/:name`) — any other attached file as a
* chip that opens its preview through `onOpenFile` —, and Claude Code's own
* `[Image: source: <drop>]` line for it joins that turn rather than showing as * `[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 * a System row; URLs and absolute paths in a turn are links (a path opens
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured * through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
@@ -14,11 +15,18 @@
* files ends with Claude Desktop's "Edited N files" card: every file with the * 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 * lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`), and a row (or the card's header, for * when the caller can (`onOpenFile`), and a row (or the card's header, for
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is * every file in it) drags into a prompt box as an `@path` attachment. An
* AskUserQuestion is its own card (`AskCard`), not part of the collapsed work
* line: each question with the answer the user picked or typed. A thinking block that carries text is
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real * 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 * 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 * chat (`live`) ends, until its turn is over, with what the model is doing
* now — the call still running, else "Thinking…" — and for how long. * now — the calls still running (several when it runs them in parallel),
* else "Thinking…", or the question the CLI waits on with a way to its
* terminal (`waiting`) — and, like the terminal's status line, how long the
* turn has run and the output tokens it has made; a call running long shows
* its own time, and a line silent for minutes turns amber. A turn that ended
* closes with how long it took and its output.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -67,7 +75,7 @@
* *
* ----------------------------------------------------------------------------- */ * ----------------------------------------------------------------------------- */
import { Fragment, memo, useEffect, useState, useMemo } from "react"; import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox"; import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "../../i18n"; import i18n from "../../i18n";
@@ -86,6 +94,11 @@ import {
Cog, Cog,
FileText, FileText,
Loader2, Loader2,
Check,
Hand,
SquareTerminal,
MessageCircleQuestion,
CornerDownRight,
} from "lucide-react"; } from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -142,6 +155,14 @@ interface MessageListProps {
onOpenFile?: (path: string) => void; onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */ /** The session is running in a pane: show the step in progress. */
live?: boolean; live?: boolean;
/** The CLI is asking the user something (a permission prompt): the live
* line says so instead of "Thinking…". */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the turn on screen is over, even when the
* transcript never says so. */
stopped?: number;
} }
/** Build a map from tool_use id → tool_result for matching */ /** Build a map from tool_use id → tool_result for matching */
@@ -160,7 +181,7 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
/** The whole prompt a message was answered from: `input_tokens` is only its /** 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. */ * uncached tail (a few tokens once the cache is warm), the rest is cache. */
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number { export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
return ( return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
); );
@@ -444,7 +465,9 @@ type Item =
| { kind: "user"; key: string; msg: TranscriptMessage } | { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string } | { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] } | { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] }; | { kind: "ask"; key: string; block: TranscriptContent }
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
| { kind: "done"; key: string; ms: number; output: number };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
@@ -453,9 +476,20 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
// The file-changing calls of the turn so far, closed into one card when the // The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends). // human speaks again (or the list ends).
let edits: TranscriptContent[] = []; let edits: TranscriptContent[] = [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
let reply: TranscriptMessage | null = null;
let output = outputCounter();
const endTurn = (key: string) => { const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits }); if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = []; edits = [];
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
}
reply = null;
output = outputCounter();
}; };
messages.forEach((msg, idx) => { messages.forEach((msg, idx) => {
const key = keys[idx]!; const key = keys[idx]!;
@@ -465,6 +499,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user"); const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") { if (sender === "user") {
endTurn(key); endTurn(key);
start = msg.timestamp ?? null;
return items.push({ kind: "user", key, msg }); return items.push({ kind: "user", key, msg });
} }
if (sender !== "assistant") { if (sender !== "assistant") {
@@ -487,10 +522,15 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
} }
return items.push({ kind: "row", key, msg }); return items.push({ kind: "row", key, msg });
} }
reply = msg;
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => { msg.content.forEach((block, b) => {
const last = items[items.length - 1]; const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block); if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (block.type === "tool_use") { // A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
} else if (block.type === "tool_use") {
if (last?.kind === "work") last.blocks.push(block); if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] }); else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) { } else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
@@ -575,14 +615,31 @@ function EditedFiles({
); );
} }
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) { export function MessageList({
messages,
loading,
onOpenFile,
live = false,
waiting = false,
onOpenTerminal,
stopped = 0,
}: MessageListProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
);
const keys = useMemo(() => messageKeys(messages), [messages]); const keys = useMemo(() => messageKeys(messages), [messages]);
const lastKey = keys[keys.length - 1] ?? null;
// The newest line when Stop was pressed. Esc before the model wrote anything
// puts the prompt back in Claude Code's input box and writes no
// "[Request interrupted…]" line (issue #74), so the transcript still ends on
// the prompt: no live line until a newer one arrives.
const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null });
if (stop.n !== stopped) setStop({ n: stopped, on: lastKey });
const halted = stop.on !== null && stop.on === lastKey;
const pending = useMemo(
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
[live, halted, messages, toolResultMap]
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]); const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
if (loading) { if (loading) {
@@ -620,6 +677,14 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
return ( return (
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} /> <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
); );
case "ask":
return (
<AskCard
key={item.key}
block={item.block}
result={item.block.id ? (toolResultMap.get(item.block.id) ?? null) : null}
/>
);
case "edits": case "edits":
// The card closes a finished turn; one still working has none yet. // The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null; if (pending && item === items[items.length - 1]) return null;
@@ -631,9 +696,19 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
); );
case "done":
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
} }
})} })}
{pending && <RunningStep block={pending.block} since={pending.since} />} {pending && (
<RunningStep
{...pending}
start={stats?.start ?? null}
tokens={stats?.output ?? 0}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
/>
)}
</div> </div>
</OpenFileContext.Provider> </OpenFileContext.Provider>
); );
@@ -643,88 +718,334 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
// written (a crash, a kill) — stop saying it works. // written (a crash, a kill) — stop saying it works.
const IDLE_AFTER_MS = 15 * 60_000; const IDLE_AFTER_MS = 15 * 60_000;
/** What the model is doing while its turn is not over: the call still /** What the model is doing while its turn is not over: the calls of the turn
* waiting for its result (`block`), else thinking (`block: null`); `since` * still waiting for their results (`blocks`, oldest first — parallel calls
* is that line's time. Null once the turn ended. */ * finish in any order), `since` the oldest one's time, `last` the newest
* line's; `blocks` is empty while it thinks. Null once the turn ended. */
export function workingOn( export function workingOn(
messages: TranscriptMessage[], messages: TranscriptMessage[],
results: Map<string, TranscriptContent>, results: Map<string, TranscriptContent>,
now = Date.now() now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null { ): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null {
const blocks: TranscriptContent[] = [];
let since: string | null = null;
let last: string | null = null;
// The newest line's verdict: undefined until it is found.
let alive: boolean | undefined;
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!; const msg = messages[i]!;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user"); const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
// A rename marker or a harness line says nothing about the turn. // A rename marker or a harness line says nothing about the turn.
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue; if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
const since = msg.timestamp ?? null; const at = msg.timestamp ?? null;
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS; const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
const newest = alive === undefined;
if (newest) last = at;
if (msg.type === "assistant") { if (msg.type === "assistant") {
const call = [...msg.content].reverse().find((b) => b.type === "tool_use"); // "end_turn": over, whatever the transcript failed to pair.
if (call?.id && !results.has(call.id)) return { block: call, since }; if (newest && msg.stop_reason === "end_turn") return null;
// "tool_use": a call follows. "end_turn" (or none, from an older server): over. for (const b of [...msg.content].reverse()) {
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null; if (b.type === "tool_use" && b.id && !results.has(b.id)) {
blocks.unshift(b);
since = at;
}
}
// "tool_use": a call follows. None (an older server): over.
if (newest) alive = msg.stop_reason === "tool_use" && fresh;
continue;
} }
// A prompt or a tool result: the model answers it next — unless the user stopped it. // 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 ?? ""; const text = msg.content.find((c) => c.type === "text")?.text ?? "";
if (text.startsWith("[Request interrupted")) return null; if (text.startsWith("[Request interrupted")) return null;
return fresh ? { block: null, since } : null; if (newest) alive = fresh;
// The prompt that opened the turn: nothing before it is this turn's.
if (text) break;
} }
return null; if (blocks.length) return { blocks, since, last };
return alive ? { blocks, since: null, last } : null;
} }
/** Claude Desktop's live line: the step running now and for how long. */ /** Output tokens counted once per API call: Claude Code writes one line per
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) { * content block, each repeating its call's usage — consecutive lines with
* the same prompt size are one call. */
function outputCounter() {
let total = 0;
let call: string | null = null;
let callOutput = 0;
return {
add(u: NonNullable<TranscriptMessage["usage"]>) {
const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`;
if (key !== call) {
total += callOutput;
call = key;
callOutput = 0;
}
callOutput = Math.max(callOutput, u.output_tokens || 0);
},
get total() {
return total + callOutput;
},
};
}
/** The live turn so far, as the terminal counts it: `start` is the prompt
* that opened it, `output` the output tokens of every API call since. */
export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } {
const output = outputCounter();
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
if (msg.type === "assistant") {
if (msg.usage) output.add(msg.usage);
continue;
}
const sender = msg.sender ?? "user";
if (sender === "user" && msg.content.some((c) => c.type === "text")) {
return { start: msg.timestamp ?? null, output: output.total };
}
}
return { start: null, output: output.total };
}
// A call running this long shows its own time beside the turn's.
const STEP_SHOWN_MS = 30_000;
// No new line for this long: the line turns amber — the step may be stuck.
const QUIET_MS = 3 * 60_000;
/** A number that counts up to its new value instead of jumping (none when
* the user asked for reduced motion). */
function useCountUp(value: number): number {
const [shown, setShown] = useState(value);
const shownRef = useRef(value);
useEffect(() => {
const from = shownRef.current;
if (from === value) return;
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduce || typeof requestAnimationFrame === "undefined") {
shownRef.current = value;
setShown(value);
return;
}
const t0 = performance.now();
let raf = 0;
const step = (t: number) => {
const k = Math.min(1, (t - t0) / 600);
shownRef.current = Math.round(from + (value - from) * k);
setShown(shownRef.current);
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [value]);
return shown;
}
/** A call's one-line title: its description (Claude Code's Bash
* `description`), else the tool and its target. */
function stepTitle(block: TranscriptContent): string {
const input = block.input as Record<string, unknown> | undefined;
return typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
}
/** The terminal's live line: the step running now (or the question the CLI
* waits on), then the turn's time and output tokens so far. */
function RunningStep({
blocks,
since,
last,
start,
tokens,
waiting,
onOpenTerminal,
}: {
blocks: TranscriptContent[];
since: string | null;
last: string | null;
start: string | null;
tokens: number;
waiting: boolean;
onOpenTerminal?: () => void;
}) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000); const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
const input = block?.input as Record<string, unknown> | undefined; const counted = useCountUp(tokens);
const title = !block const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
? t("conversation.working") const first = blocks[0];
: typeof input?.description === "string" && input.description const step = first ? age(since) : 0;
? input.description const quiet = !waiting && age(last) > QUIET_MS;
: [block.name, buildSummary(block)].filter(Boolean).join(" "); const title = waiting
? first
? t("conversation.waitingFor", { step: stepTitle(first) })
: t("conversation.waiting")
: first
? stepTitle(first)
: t("conversation.working");
const extra = [
start && formatMs(age(start)),
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
].filter(Boolean);
const amber = waiting || quiet;
return ( return (
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted"> <div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" /> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 truncate">{title}</span> {waiting ? (
{since && ( <Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
<span className="flex-shrink-0 font-mono text-xs"> ) : (
{formatMs(now - parseDate(since).getTime())} <Loader2
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
/>
)}
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
{title}
</span> </span>
{blocks.length > 1 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
aria-expanded={showAll}
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
>
{t("conversation.moreSteps", { count: blocks.length - 1 })}
</button>
)}
{step > STEP_SHOWN_MS && (
<span
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
>
{formatMs(step)}
</span>
)}
{extra.length > 0 && (
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
)}
{waiting && onOpenTerminal && (
<button
type="button"
data-testid="chat-open-terminal"
onClick={onOpenTerminal}
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
>
<SquareTerminal className="h-3.5 w-3.5" />
{t("conversation.openTerminal")}
</button>
)}
</div>
{showAll && blocks.length > 1 && (
<ul className="ml-5 space-y-0.5 text-xs">
{blocks.map((b, i) => (
<li key={b.id ?? i} className="truncate">
{stepTitle(b)}
</li>
))}
</ul>
)} )}
</div> </div>
); );
} }
/** Under a turn that ended: how long it took and the output it made. */
/** An AskUserQuestion: each question with the answer the user picked or
* typed (the result's `answers`), "declined" when they refused it, no answer
* yet while the CLI still asks. */
function AskCard({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const input = block.input as { questions?: unknown } | undefined;
const questions = (Array.isArray(input?.questions) ? input.questions : [])
.map((q) => (q && typeof q === "object" ? (q as { question?: unknown }).question : null))
.filter((q): q is string => typeof q === "string");
return (
<div
data-testid="chat-ask"
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
>
{questions.map((q, i) => {
const answer = result?.answers?.[q];
return (
<div key={i}>
<div className="flex items-start gap-1.5 text-fg-secondary">
<MessageCircleQuestion className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{q}</span>
</div>
{answer && (
<div className="ml-5 mt-0.5 flex items-start gap-1.5 font-medium text-fg-primary">
<CornerDownRight className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="whitespace-pre-wrap">{answer}</span>
</div>
)}
</div>
);
})}
{result?.is_error && (
<div className="text-xs text-fg-muted">{t("conversation.askDeclined")}</div>
)}
</div>
);
}
function TurnDone({ ms, output }: { ms: number; output: number }) {
const { t } = useTranslation("sessions");
return (
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
<Check className="h-3 w-3 flex-shrink-0" />
{[
t("conversation.turnDone", { time: formatMs(ms) }),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
]
.filter(Boolean)
.join(" · ")}
</div>
);
}
const meta = "text-[10px] font-mono text-fg-muted"; const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached image: its saved path under the // What the prompt box sends for an attached file: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the // drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts). // older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
const DROP_IMAGE = const DROP_PATH =
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi; /(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i;
// Claude Code's own line for an image it read off a pasted path (`isMeta`). // Claude Code's own line for an image it read off a pasted path (`isMeta`).
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/; const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
/** A user turn's text without its dropped-image paths (and Claude Code's /** A user turn's text without its dropped-file paths (and Claude Code's
* `[Image #N]` placeholders for them), and those images' names. */ * `[Image #N]` placeholders for them), the images' names and the other
export function splitDropImages(text: string): { text: string; images: string[] } { * files' full paths. */
export function splitDropImages(text: string): {
text: string;
images: string[];
files: string[];
} {
const images: string[] = []; const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => { const files: string[] = [];
images.push(name); const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
if (IMAGE_EXT.test(name)) images.push(name);
else files.push(full);
return ""; return "";
}); });
return { return {
text: images.length text:
? rest images.length || files.length
.replace(/\[Image #\d+\]/g, "") ? rest
.replace(/[ \t]+$/gm, "") .replace(/\[Image #\d+\]/g, "")
.trim() .replace(/[ \t]+$/gm, "")
: text, .trim()
: text,
images, images,
files,
}; };
} }
@@ -735,6 +1056,30 @@ function imageSourcePaths(msg: TranscriptMessage): string[] | null {
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null; return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
} }
/** A dropped file other than an image: a chip that opens its preview when the
* page can (`OpenFileContext`). */
function DropFile({ path }: { path: string }) {
const open = useContext(OpenFileContext);
const name = path
.split(/[\\/]/)
.pop()!
.replace(/^[0-9a-f]{16}-?/i, "");
const ext = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toUpperCase();
return (
<button
type="button"
disabled={!open}
onClick={() => open?.(path)}
title={path}
className="flex max-w-64 items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-left text-xs enabled:hover:border-accent/50"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate text-fg-primary">{name}</span>
{ext && <span className="shrink-0 font-mono text-[10px] text-fg-muted">{ext}</span>}
</button>
);
}
function DropImage({ name }: { name: string }) { function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false); const [gone, setGone] = useState(false);
@@ -782,6 +1127,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
[msg.content] [msg.content]
); );
const images = blocks.flatMap((b) => b?.images ?? []); const images = blocks.flatMap((b) => b?.images ?? []);
const files = blocks.flatMap((b) => b?.files ?? []);
const hasText = blocks.some((b) => b?.text); const hasText = blocks.some((b) => b?.text);
return ( return (
<div <div
@@ -789,7 +1135,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }} style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1" className="group flex flex-col items-end gap-1"
> >
{images.length > 0 && ( {images.length + files.length > 0 && (
<div <div
data-testid="chat-user-images" data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2" className="flex max-w-[85%] flex-wrap justify-end gap-2"
@@ -797,6 +1143,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
{images.map((name) => ( {images.map((name) => (
<DropImage key={name} name={name} /> <DropImage key={name} name={name} />
))} ))}
{files.map((path) => (
<DropFile key={path} path={path} />
))}
</div> </div>
)} )}
{hasText && ( {hasText && (
@@ -1,11 +1,13 @@
/** /**
* @file ConversationView.notYet.test.tsx * @file ConversationView.notYet.test.tsx
* @description A live pane's new session has no row until its first hook: * @description A live pane's new session has no row until its first hook:
* its 404 shows an empty chat, not "Session not found"; a replay still shows it. * its 404 shows no "Session not found" (a replay still shows it) but says the
* session has not started — the CLI may be asking in the terminal, e.g. to
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView"; import { ConversationView } from "../ConversationView";
@@ -34,4 +36,23 @@ describe("ConversationView before a session's first hook", () => {
render(<ConversationView sessionId="gone" fill />); render(<ConversationView sessionId="gone" fill />);
expect(await screen.findByText("Session not found")).toBeTruthy(); expect(await screen.findByText("Session not found")).toBeTruthy();
}); });
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
const onOpenTerminal = vi.fn();
const onNotStarted = vi.fn();
render(
<ConversationView
sessionId="new"
fill
live
onOpenTerminal={onOpenTerminal}
onNotStarted={onNotStarted}
/>
);
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
expect(screen.queryByText("No conversation records found.")).toBeNull();
fireEvent.click(screen.getByText("Open terminal"));
expect(onOpenTerminal).toHaveBeenCalled();
expect(onNotStarted).toHaveBeenLastCalledWith(true);
});
}); });
@@ -0,0 +1,85 @@
/**
* @file MessageList.ask.test.tsx
* @description An AskUserQuestion call is not folded into the collapsed work
* line: the chat shows it as its own card — each question with the answer the
* user picked or typed, "declined" when they refused it, nothing yet while the
* CLI still waits.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-13T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const ask = (id: string, questions: string[]): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
{
type: "tool_use",
id,
name: "AskUserQuestion",
input: {
questions: questions.map((question) => ({
question,
header: "Pick",
options: [{ label: "Yes" }, { label: "No" }],
})),
},
},
],
}) as TranscriptMessage;
const result = (id: string, extra: Record<string, unknown>) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "x", ...extra }],
}) as TranscriptMessage;
describe("an AskUserQuestion card", () => {
it("shows each question with the user's answer, outside the work line", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Push?"]),
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("only after review")).toBeTruthy();
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
});
it("says declined when refused, and shows no answer while still asked", () => {
const { rerender } = render(
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Commit?")).toBeTruthy();
expect(within(card).queryByText(/declined/i)).toBeNull();
rerender(
<MessageList
loading={false}
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
/>
);
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
});
});
@@ -2,8 +2,8 @@
* @file MessageList.images.test.tsx * @file MessageList.images.test.tsx
* @description An image the prompt box attached reaches the transcript as its * @description An image the prompt box attached reaches the transcript as its
* saved drop path; the user's turn shows the image (served by * 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 * `GET /api/run/drop/:name`), opens it full size on the page; any other
* other path as typed. Claude Code turns a pasted drop path into an image * dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
* block plus an `[Image: source: <path>]` meta line: the image joins the turn. * block plus an `[Image: source: <path>]` meta line: the image joins the turn.
* URLs and absolute paths in a turn are links. * URLs and absolute paths in a turn are links.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -19,7 +19,7 @@ describe("dropped images in a user turn", () => {
it("splitDropImages takes drop-dir image paths out of the text", () => { it("splitDropImages takes drop-dir image paths out of the text", () => {
expect( expect(
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`) splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] }); ).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
// Windows' temp dir, no prefix name. // Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([ expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp", "0123456789abcdef.webp",
@@ -28,16 +28,36 @@ describe("dropped images in a user turn", () => {
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({ expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
text: "fix this", text: "fix this",
images: [SHOT], images: [SHOT],
files: [],
}); });
// Not a drop, or not an image: left as typed. // Any other dropped file comes out by its full path.
for (const text of [ const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
"/home/me/shot.png", expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf", // Not a drop: left as typed.
]) { for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
expect(splitDropImages(text)).toEqual({ text, images: [] }); expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
} }
}); });
it("shows a dropped file as a chip that opens its preview", () => {
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `summarise ${pdf}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).not.toHaveTextContent("0123456789abcdef");
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
expect(opened).toEqual([pdf]);
});
it("shows the image instead of its path, full size on click", () => { it("shows the image instead of its path, full size on click", () => {
const messages = [ const messages = [
{ {
@@ -10,7 +10,7 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, workLabel, workingOn } from "../MessageList"; import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types"; import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage { function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -142,11 +142,17 @@ describe("MessageList — sender attribution", () => {
it("ends a live chat with the step still running", () => { it("ends a live chat with the step still running", () => {
const messages = [ const messages = [
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }), msg({
type: "user",
sender: "user",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [{ type: "text", text: "test it" }],
}),
msg({ msg({
type: "assistant", type: "assistant",
sender: "assistant", sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(), timestamp: new Date(Date.now() - 5_000).toISOString(),
usage: { input_tokens: 2, output_tokens: 3500 },
content: [ content: [
{ {
type: "tool_use", type: "tool_use",
@@ -160,7 +166,8 @@ describe("MessageList — sender attribution", () => {
const { rerender } = render(<MessageList loading={false} messages={messages} live />); const { rerender } = render(<MessageList loading={false} messages={messages} live />);
const row = screen.getByTestId("chat-running-step"); const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite"); expect(row).toHaveTextContent("Run the full client suite");
expect(row).toHaveTextContent(/1m \ds/); // Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
// Its result arrived: nothing is running any more. // Its result arrived: nothing is running any more.
rerender( rerender(
<MessageList <MessageList
@@ -175,6 +182,121 @@ describe("MessageList — sender attribution", () => {
expect(screen.queryByTestId("chat-running-step")).toBeNull(); expect(screen.queryByTestId("chat-running-step")).toBeNull();
}); });
// Issue #74: Esc before the model wrote anything takes the prompt back into
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
const prompt = msg({
sender: "user",
timestamp: new Date(Date.now() - 14_000).toISOString(),
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
});
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
// The next prompt is a new turn: its Thinking… shows.
const next = msg({
sender: "user",
timestamp: new Date().toISOString(),
content: [{ type: "text", text: "again" }],
});
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
});
it("counts a turn's output once per API call, since its prompt", () => {
const at = "2026-06-26T08:19:00.000Z";
const line = (cache: number, out: number) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "x" }],
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
});
const messages = [
msg({ sender: "user" }),
line(10, 999), // an earlier turn
msg({ sender: "user", timestamp: at }),
line(100, 40),
line(100, 40), // the same call's second block
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
line(200, 25),
];
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
});
it("keeps every parallel call of the turn running until its own result", () => {
const at = "2026-06-26T08:19:00.000Z";
const now = Date.parse("2026-06-26T08:20:00.000Z");
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
});
const done = (id: string) =>
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
// Claude Code writes each parallel call on its own line; one result is in.
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
const results = new Map([["b", m[4]!.content[0]!]]);
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
// A call left unpaired in an EARLIER turn is not this one's.
const next = [
...m,
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
];
expect(workingOn(next, results, now)?.blocks).toEqual([]);
});
it("says when the CLI waits on the user, and offers the terminal", () => {
let opened = 0;
render(
<MessageList
loading={false}
live
waiting
onOpenTerminal={() => opened++}
messages={[
msg({ sender: "user", timestamp: new Date().toISOString() }),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date().toISOString(),
content: [
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
],
}),
]}
/>
);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
fireEvent.click(screen.getByTestId("chat-open-terminal"));
expect(opened).toBe(1);
});
it("closes an ended turn with its time and output", () => {
const reply = (out: number, stop_reason: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: "2026-06-26T08:16:08.000Z",
stop_reason,
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
content: [{ type: "text", text: "done" }],
});
render(
<MessageList
loading={false}
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
/>
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
});
it("knows what a live turn is doing until it ends", () => { it("knows what a live turn is doing until it ends", () => {
const now = Date.parse("2026-06-26T08:20:00.000Z"); const now = Date.parse("2026-06-26T08:20:00.000Z");
const at = "2026-06-26T08:19:00.000Z"; const at = "2026-06-26T08:19:00.000Z";
@@ -205,10 +327,11 @@ describe("MessageList — sender attribution", () => {
); );
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now); const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt const thinking = { blocks: [], since: null, last: at };
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running expect(state([prompt])).toEqual(thinking); // thinking on the prompt
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows expect(state([prompt, call, result])).toEqual(thinking); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
const stopped = msg({ const stopped = msg({
@@ -1,26 +1,29 @@
/** /**
* @file A server-backed folder browser for the Add Lane modal's path inputs. * @file A server-backed folder (or file) browser for the path inputs' Browse
* Browsers refuse to expose an absolute filesystem path from a native folder * button. Browsers refuse to expose an absolute filesystem path from a native
* picker, so path selection here is done by browsing `GET /api/lanes/browse` * picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path) instead — breadcrumb-free, just an * (immediate subdirectories of a path, plus its files for `kind="file"`)
* up-one-level button and a click-to-descend list, since this tool is * instead — breadcrumb-free, just an up-one-level button and a
* local-first and the server already trusts arbitrary local paths. * click-to-descend list; in file mode a click on a file picks it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react"; import { FolderOpen, FolderGit2, ArrowUp, File } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
export function FolderBrowseModal({ export function FolderBrowseModal({
open, open,
kind = "dir",
initialPath, initialPath,
onSelect, onSelect,
onClose, onClose,
}: { }: {
open: boolean; open: boolean;
/** `file` lists files too, and picking one is the only way to select. */
kind?: "dir" | "file";
/** Path to start browsing from; omitted defaults server-side to the home dir. */ /** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string; initialPath?: string;
onSelect: (path: string) => void; onSelect: (path: string) => void;
@@ -33,7 +36,7 @@ export function FolderBrowseModal({
const load = async (path?: string) => { const load = async (path?: string) => {
setError(null); setError(null);
try { try {
setListing(await api.lanes.browse(path)); setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path)));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
@@ -65,7 +68,7 @@ export function FolderBrowseModal({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("browse.title")} aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
data-tour="folder-browse" data-tour="folder-browse"
> >
<div className="border-b border-border p-3"> <div className="border-b border-border p-3">
@@ -94,10 +97,16 @@ export function FolderBrowseModal({
<button <button
key={entry.path} key={entry.path}
type="button" type="button"
onClick={() => load(entry.path)} onClick={() => {
if (!entry.file) return void load(entry.path);
onSelect(entry.path);
onClose();
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
> >
{entry.isGitRepo ? ( {entry.file ? (
<File className="h-3.5 w-3.5 text-fg-muted" />
) : entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-accent" /> <FolderGit2 className="h-3.5 w-3.5 text-accent" />
) : ( ) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" /> <FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
@@ -118,17 +127,19 @@ export function FolderBrowseModal({
> >
{t("destructive.cancel")} {t("destructive.cancel")}
</button> </button>
<button {kind === "dir" && (
type="button" <button
disabled={!listing} type="button"
onClick={() => { disabled={!listing}
if (listing) onSelect(listing.path); onClick={() => {
onClose(); if (listing) onSelect(listing.path);
}} onClose();
className="btn-primary text-xs disabled:opacity-50" }}
> className="btn-primary text-xs disabled:opacity-50"
{t("browse.select")} >
</button> {t("browse.select")}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -277,6 +277,13 @@ function HomeBody({
...(claude && inWorktree && project?.root ? { worktree: true } : {}), ...(claude && inWorktree && project?.root ? { worktree: true } : {}),
}); });
setPrompt(""); setPrompt("");
// Here, not only through the effect: the navigate below unmounts this
// home in the same batch, so the effect never sees the empty prompt.
try {
localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: nothing was kept */
}
images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]); setImages([]);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
@@ -14,7 +14,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types"; import type { Lane, Session } from "../../../lib/types";
import type { LaneSourcesView, RunHandle } from "../../../lib/api"; import type { LaneSourcesView, RunHandle } from "../../../lib/api";
@@ -241,6 +241,27 @@ describe("WorkspaceHome", () => {
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull()); await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
}); });
it("drops the sent prompt's draft even though opening the session unmounts the home", async () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
}
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Routes>
<Route path="*" element={<HomeUntilRun />} />
</Routes>
</MemoryRouter>
);
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "sent once");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(screen.queryByTestId("workspace-home-start")).toBeNull());
expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull();
});
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => { it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
renderHome(); renderHome();
await screen.findByTestId("workspace-recent-s-live"); await screen.findByTestId("workspace-recent-s-live");
@@ -171,8 +171,16 @@ export function OnboardingTour() {
style = { left: right + 16, top }; style = { left: right + 16, top };
} else if (rect.bottom + 180 < window.innerHeight) { } else if (rect.bottom + 180 < window.innerHeight) {
style = { left, top: rect.bottom + 12 }; style = { left, top: rect.bottom + 12 };
} else if (rect.top > 200) {
style = { left, top: rect.top - 12, transform: "translateY(-100%)" };
} else { } else {
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" }; // A target filling the screen (the live session): no room above or
// below, so the card sits inside its bottom-right corner.
style = {
left: Math.min(Math.max(12, rect.right - W - 16), window.innerWidth - W - 12),
top: Math.min(rect.bottom, window.innerHeight) - 16,
transform: "translateY(-100%)",
};
} }
} }
+33 -6
View File
@@ -2,7 +2,8 @@
* @file ChatOutline.tsx * @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane, laid out like * @description The right-hand panel of a Chat pane, laid out like
* Claude Desktop's activity panel: the session's Progress (its * Claude Desktop's activity panel: the session's Progress (its
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs * TaskCreate/TaskUpdate or TodoWrite plan, only when it has one — how many
* steps are done of how many, a bar, and the step in progress highlighted), the Outputs
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in * 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 * this session" — the files attached to its prompts, the files it read and the
* memory files it used and its skills (a click opens the list with a * memory files it used and its skills (a click opens the list with a
@@ -107,7 +108,8 @@ function Section({
children, children,
}: { }: {
title: string; title: string;
count?: number; /** Beside the title: a count, or a "3/7". */
count?: number | string;
action?: ReactNode; action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */ /** Takes the panel's free height and scrolls inside it. */
grow?: boolean; grow?: boolean;
@@ -211,6 +213,7 @@ export function ChatOutline({
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const href = useSettingsHref(); const href = useSettingsHref();
const doneSteps = o.progress.filter((p) => p.status === "completed").length;
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length; const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = ( const close = (
<button <button
@@ -228,16 +231,40 @@ export function ChatOutline({
<aside <aside
data-testid="chat-outline" data-testid="chat-outline"
aria-label={t("chat.outline.panel")} aria-label={t("chat.outline.panel")}
// Over the chat in a narrow pane (the container is PaneChat's root), // Over the conversation in a narrow pane (the container is PaneChat's
// root; `--chat-area` keeps the prompt box below it uncovered),
// beside it once the pane has room, as wide as PaneChat's splitter says. // beside it once the pane has room, as wide as PaneChat's splitter says.
className="absolute inset-y-3 right-3 z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none" className="absolute right-3 top-3 bottom-[calc(100%-var(--chat-area,100%)+0.75rem)] z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
> >
{o.progress.length > 0 && ( {o.progress.length > 0 && (
<Section title={t("chat.outline.progress")} action={close}> <Section
title={t("chat.outline.progress")}
count={`${doneSteps}/${o.progress.length}`}
action={close}
>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={o.progress.length}
aria-valuenow={doneSteps}
aria-label={t("chat.outline.progress")}
className="mx-1.5 mb-1.5 h-1 overflow-hidden rounded-full bg-surface-3"
>
<div
className="h-full rounded-full bg-accent-solid transition-[width] duration-500"
style={{ width: `${(doneSteps / o.progress.length) * 100}%` }}
/>
</div>
{o.progress.map((task) => { {o.progress.map((task) => {
const done = task.status === "completed"; const done = task.status === "completed";
const now = task.status === "in_progress";
return ( return (
<div key={task.id} className={rowClass} title={task.subject}> <div
key={task.id}
className={`${rowClass} ${now ? "bg-accent/10 font-medium" : ""}`}
title={task.subject}
aria-current={now ? "step" : undefined}
>
{done ? ( {done ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white"> <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" /> <Check className="h-3 w-3" />
+331 -27
View File
@@ -12,11 +12,18 @@
* `/model <id>` / `/effort <level>` — and the permission mode, which only * `/model <id>` / `/effort <level>` — and the permission mode, which only
* Shift+Tab changes in a running session, so the server steps it and reads * 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 * 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. * in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
* permission or trust prompt, Codex's approval — is read off the terminal
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
* a field in the card, whose text is typed there as the answer
* (`answerRunMenu`).
* It also has Claude Desktop's activity panel (`ChatOutline`: progress, * It also has Claude Desktop's activity panel (`ChatOutline`: progress,
* outputs, what the session used), open at first in a pane spanning the * outputs, what the session used), open at first in a pane spanning the
* grid's full width or height (`panelOpen`) and collapsed into a file button * grid's full width or height (`panelOpen`) once the session has an output,
* with the output count in a quarter-sized one; it sits beside the * else collapsed into a file button with the output count; the user's own
* open/close is kept per session for the tab (sessionStorage), so the chat
* leaving for the terminal and back keeps it; it sits beside the
* conversation when the pane is wide and over it when narrow (a CSS container * conversation when the pane is wide and over it when narrow (a CSS container
* query on the pane, not the window), dragged wider or narrower by its * query on the pane, not the window), dragged wider or narrower by its
* splitter there (a fraction of the pane, remembered per browser). A file * splitter there (a fraction of the pane, remembered per browser). A file
@@ -34,13 +41,23 @@
* conversation to its end and keeps it there (`follow`), so the message and * 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 * 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 * main agent is working (a prompt submitted, no Stop yet) an empty box shows
* Stop in Send's place: Esc pressed in the pane (`interruptRun`). * Stop in Send's place: Esc pressed in the pane (`interruptRun`). While the
* CLI asks the user something (the main agent awaiting input on a
* Notification — a permission prompt), the chat's live line says so and
* offers the terminal (`showTerminal`); before the session has started (no
* row yet: a trust prompt, say, answered only there) anything typed into the
* pane is confirmed first, so an Enter does not pick that prompt's
* preselected "No, exit" unasked. The box shows how full the context
* is (the newest reply's prompt size against the model's window,
* `contextWindow`), and a hidden browser tab is marked while the session
* waits on the user or once its turn ended (`lib/tabBadge.ts`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square } from "lucide-react"; import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView"; import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup"; import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu"; import { ChatAddMenu } from "./ChatAddMenu";
@@ -49,11 +66,22 @@ import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline"; import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview"; import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select"; import { Select } from "../Select";
import { Divider } from "./PaneGrid"; import { Divider, showTerminal } from "./PaneGrid";
import { interruptRun, submitToRun } from "../../lib/panePaste"; import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh"; import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges"; import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format";
import { agentAwaitingReason } from "../../lib/types";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api"; import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [ const MODES: [PermissionMode, string][] = [
@@ -66,11 +94,68 @@ const MODES: [PermissionMode, string][] = [
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m); const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
const SIDE_KEY = "agenthub.chat.panelWidth"; const SIDE_KEY = "agenthub.chat.panelWidth";
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100; const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
const readOutlineChoice = (sessionId: string) => {
const v = sessionStorage.getItem(outlineKey(sessionId));
return v === null ? null : v === "1";
};
const storedSide = () => { const storedSide = () => {
const f = Number(localStorage.getItem(SIDE_KEY)); const f = Number(localStorage.getItem(SIDE_KEY));
return f ? clampSide(f) : null; return f ? clampSide(f) : null;
}; };
/** The model's context window. Claude's 200K models are the 4.5 line and
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
export function contextWindow(model: string | null): number | null {
if (!model?.startsWith("claude-")) return null;
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
? 200_000
: 1_000_000;
}
/** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
const { t } = useTranslation("run");
const size = contextWindow(model);
if (!size || !tokens) return null;
const pct = Math.min(100, Math.round((tokens / size) * 100));
const tone =
pct >= 90 ? "text-status-danger" : pct >= 75 ? "text-status-warning" : "text-fg-muted";
const ring = 2 * Math.PI * 6;
return (
<span
data-testid="pane-chat-context"
title={t("chat.contextTitle", { used: fmt(tokens), size: fmt(size) })}
className={`flex items-center gap-1 px-1 text-xs tabular-nums ${tone}`}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 -rotate-90" aria-hidden>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={`${(pct / 100) * ring} ${ring}`}
/>
</svg>
{pct}%
</span>
);
}
/** The pane's permission mode as it shows it; a pick steps it there. */ /** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) { function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
const { t } = useTranslation(["run", "lanes"]); const { t } = useTranslation(["run", "lanes"]);
@@ -119,6 +204,108 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
); );
} }
/** The menu the CLI asks with, as buttons: a pick presses its key in the
* pane; its "Type something" row opens a field right there instead —
* pressing that row's digit again would type the digit into the CLI's own. */
function PaneMenuCard({
menu,
onChoose,
onAnswer,
onCancel,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onAnswer: (text: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation("run");
const [typing, setTyping] = useState(false);
const [draft, setDraft] = useState("");
const submit = () => draft.trim() && onAnswer(draft);
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
{menu.question && (
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
)}
<div className="flex flex-col gap-1">
{menu.options.map((o, i) =>
typing && i === menu.typeAt ? (
<div
key={`${i}-type`}
className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1"
>
<span className="w-4 flex-shrink-0 font-mono text-xs text-fg-muted">{o.n}</span>
<input
autoFocus
data-testid="pane-chat-menu-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
submit();
} else if (e.key === "Escape") setTyping(false);
}}
placeholder={t("chat.menu.placeholder")}
aria-label={t("chat.menu.placeholder")}
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
/>
<button
type="button"
data-testid="pane-chat-menu-send"
onClick={submit}
disabled={!draft.trim()}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
key={`${i}-${o.label}`}
type="button"
onClick={() => (i === menu.typeAt ? setTyping(true) : onChoose(i))}
className={`flex items-start gap-2 rounded-lg border px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 ${
i === menu.cursor ? "border-accent/50 bg-surface-2" : "border-border"
}`}
>
<span className="mt-0.5 w-4 flex-shrink-0 font-mono text-xs text-fg-muted">
{o.checked === null ? (
(o.n ?? "↵")
) : o.checked ? (
<SquareCheck className="h-3.5 w-3.5 text-accent" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 break-words">
{o.label}
{o.hint && <span className="block text-xs text-fg-muted">{o.hint}</span>}
</span>
</button>
)
)}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
</div>
);
}
export function PaneChat({ export function PaneChat({
run, run,
sessionId, sessionId,
@@ -148,11 +335,20 @@ export function PaneChat({
const [model, setModel] = useState(run.model ?? ""); const [model, setModel] = useState(run.model ?? "");
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? ""); const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
const pickFiles = useRef<(() => void) | null>(null); const pickFiles = useRef<(() => void) | null>(null);
const [outlineOpen, setOutlineOpen] = useState(panelOpen); // The user's own open/close, per session for this tab: it outlives the chat
// leaving for the terminal (an unmount). Unset, the default rules.
const [outlineChoice, setOutlineChoice] = useState(() => readOutlineChoice(sessionId));
// A split or a close changes the pane's shape: its default comes back. // A split or a close changes the pane's shape: its default comes back.
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]); const shape = useRef(panelOpen);
useEffect(() => {
if (shape.current === panelOpen) return;
shape.current = panelOpen;
sessionStorage.removeItem(outlineKey(sessionId));
setOutlineChoice(null);
}, [panelOpen, sessionId]);
const openOutline = (open: boolean) => { const openOutline = (open: boolean) => {
setOutlineOpen(open); sessionStorage.setItem(outlineKey(sessionId), open ? "1" : "0");
setOutlineChoice(open);
setPreview(null); setPreview(null);
}; };
const [preview, setPreview] = useState<string | null>(null); const [preview, setPreview] = useState<string | null>(null);
@@ -161,20 +357,42 @@ export function PaneChat({
const [dragged, setDragged] = useState(storedSide); const [dragged, setDragged] = useState(storedSide);
const side = dragged ?? (preview ? 0.45 : 0.3); const side = dragged ?? (preview ? 0.45 : 0.3);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
// The conversation's height, so a panel laid over a narrow pane stops above
// the prompt box instead of covering it.
const areaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const area = areaRef.current;
if (!area || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() =>
rootRef.current?.style.setProperty("--chat-area", `${area.offsetHeight}px`)
);
ro.observe(area);
return () => ro.disconnect();
}, []);
const [list, setList] = useState<OutlineList | null>(null); const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId); const activity = useSessionOutline(sessionId);
// Open by default only in a spanning pane, and only once there is an output.
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId }; const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images); const text = promptWithImages(prompt, images);
// The main agent is "working" from a prompt's submit to its Stop hook. // The main agent is "working" from a prompt's submit to its Stop hook, and
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const [waiting, setWaiting] = useState(false);
// Stop presses, for the conversation's live line (issue #74).
const [stops, setStops] = useState(0);
const readWorking = useCallback( const readWorking = useCallback(
() => () =>
api.agents api.agents
.list({ session_id: sessionId, status: "working" }) .list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its // With session_id the server answers every agent of the session, its
// status filter ignored: check the status here. // status filter ignored: check the status here.
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working"))) .then((r) => {
const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working"));
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}), .catch(() => {}),
[sessionId] [sessionId]
); );
@@ -186,22 +404,83 @@ export function PaneChat({
// While Stop shows, also poll: a missed agent_updated (the hook reached // While Stop shows, also poll: a missed agent_updated (the hook reached
// another dashboard on the same database, or the socket dropped) would // another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended. // otherwise leave Stop up after the turn ended.
intervalMs: working ? 5000 : undefined, intervalMs: working || waiting ? 5000 : undefined,
match: (msg) => match: (msg) =>
msg.type === "agent_updated" && msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId, (msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
}); });
// The menu the terminal underneath shows now. AskUserQuestion fires no
// Notification, so this — not `waiting` alone — says the CLI asks.
// ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile.
const [menu, setMenu] = useState<PaneMenu | null>(null);
useEffect(() => {
const read = () =>
setMenu((prev) => {
const next = readRunMenu(runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
read();
const timer = window.setInterval(read, 700);
return () => window.clearInterval(timer);
}, [runId]);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
if (menu && chooseRunOption(runId, menu, index)) setMenu(null);
else toast.error(t("chat.notSent"));
};
const answer = (reply: string) => {
if (menu && answerRunMenu(runId, menu, reply)) {
setMenu(null);
setFollow((n) => n + 1);
} else toast.error(t("chat.notSent"));
};
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, asking: false });
useEffect(() => {
if (asking) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working, asking };
}, [runId, working, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
model: null,
});
const onContext = useCallback(
(tokens: number, model: string | null) => setContext({ tokens, model }),
[]
);
// Before the session starts the CLI may be asking in the terminal (trust
// this folder? with "No, exit" preselected): an Enter from here answers it.
// Asked, not refused: whether the session row appears right after the
// answer depends on Claude Code firing a hook then, and a hard lock would
// strand the chat if it does not.
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would. // A control is a command typed into the pane, like the user would.
const command = (line: string) => { const command = async (line: string) => {
if (
notStarted &&
!(await confirmDialog({
title: t("chat.notStartedTitle"),
message: t("chat.notStarted"),
confirmLabel: t("chat.sendAnyway"),
}))
)
return false;
const ok = submitToRun(runId, line); const ok = submitToRun(runId, line);
if (!ok) toast.error(t("chat.notSent")); if (!ok) toast.error(t("chat.notSent"));
return ok; return ok;
}; };
const send = () => { const send = async () => {
if (!text || uploading) return; if (!text || uploading) return;
if (!command(text)) return; if (!(await command(text))) return;
setFollow((n) => n + 1); setFollow((n) => n + 1);
setPrompt(""); setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
@@ -216,12 +495,17 @@ export function PaneChat({
style={{ "--chat-side": `${side * 100}%` } as CSSProperties} style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
> >
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3"> <div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
<div className="relative min-h-0 flex-1 overflow-hidden"> <div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
<ConversationView <ConversationView
sessionId={sessionId} sessionId={sessionId}
fill fill
live live
follow={follow} follow={follow}
waiting={asking}
stopped={stops}
onOpenTerminal={() => showTerminal(runId, sessionId)}
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={(path) => { onOpenFile={(path) => {
// The workspace's Changes panel when the file is in its folder; // The workspace's Changes panel when the file is in its folder;
// else this view's own preview. // else this view's own preview.
@@ -245,7 +529,24 @@ export function PaneChat({
</div> </div>
<div className="mx-4"> <div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]"> <div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50"> {menu && (
<PaneMenuCard
// A new question starts with its field closed.
key={menu.question}
menu={menu}
onChoose={choose}
onAnswer={answer}
onCancel={() => {
if (interruptRun(runId)) setMenu(null);
else toast.error(t("chat.notSent"));
}}
/>
)}
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
>
<PromptEditor <PromptEditor
bare bare
images={images} images={images}
@@ -254,7 +555,7 @@ export function PaneChat({
pickFiles={pickFiles} pickFiles={pickFiles}
value={prompt} value={prompt}
onChange={setPrompt} onChange={setPrompt}
onSubmit={send} onSubmit={() => void send()}
placeholder={t("chat.placeholder")} placeholder={t("chat.placeholder")}
rows={2} rows={2}
slash={claude} slash={claude}
@@ -272,16 +573,17 @@ export function PaneChat({
/> />
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />} {claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<ContextMeter tokens={context.tokens} model={context.model} />
{claude && ( {claude && (
<ModelMenu <ModelMenu
value={model} value={model}
onChange={(v) => { onChange={async (v) => {
if (command(`/model ${v || "default"}`)) setModel(v); if (await command(`/model ${v || "default"}`)) setModel(v);
}} }}
tool={run.tool} tool={run.tool}
effort={effort} effort={effort}
onEffortChange={(e) => { onEffortChange={async (e) => {
if (command(`/effort ${e || "auto"}`)) setEffort(e); if (await command(`/effort ${e || "auto"}`)) setEffort(e);
}} }}
/> />
)} )}
@@ -291,8 +593,10 @@ export function PaneChat({
data-testid="pane-chat-stop" data-testid="pane-chat-stop"
onClick={() => { onClick={() => {
// An interrupted turn fires no Stop hook: clear it here. // An interrupted turn fires no Stop hook: clear it here.
if (interruptRun(runId)) setWorking(false); if (interruptRun(runId)) {
else toast.error(t("chat.notStopped")); setWorking(false);
setStops((n) => n + 1);
} else toast.error(t("chat.notStopped"));
}} }}
aria-label={t("chat.stop")} aria-label={t("chat.stop")}
title={t("chat.stop")} title={t("chat.stop")}
@@ -304,7 +608,7 @@ export function PaneChat({
<button <button
type="button" type="button"
data-testid="pane-chat-send" data-testid="pane-chat-send"
onClick={send} onClick={() => void send()}
disabled={uploading || !text} disabled={uploading || !text}
aria-label={t("chat.send")} aria-label={t("chat.send")}
title={t("chat.send")} title={t("chat.send")}
@@ -325,7 +629,7 @@ export function PaneChat({
scope={scope} scope={scope}
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
// Beside the chat when the pane has room, over it when not. // Beside the chat when the pane has room, over it when not.
className="absolute inset-0 z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0" className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
/> />
) : ( ) : (
outlineOpen && ( outlineOpen && (
+14 -13
View File
@@ -201,6 +201,15 @@ const ITEM =
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The * (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
* unsent prompt goes along: the terminal's input box into the chat's draft, * unsent prompt goes along: the terminal's input box into the chat's draft,
* the draft (its files as paths) back into the input box, never sent. */ * the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
function ChatToggle({ run }: { run?: RunHandle }) { function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId); const chat = useSessionChat(run?.sessionId);
@@ -215,20 +224,12 @@ function ChatToggle({ run }: { run?: RunHandle }) {
title={label} title={label}
aria-label={label} aria-label={label}
onClick={() => { onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
const draft = readChatDraft(sessionId); const draft = readChatDraft(sessionId);
if (chat) { const text = takeRunInput(run.id);
const text = promptWithImages(draft.prompt, draft.images); if (text)
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []); writeChatDraft(sessionId, [draft.prompt, text].filter(Boolean).join("\n"), draft.images);
} else { setSessionChat(sessionId, true);
const text = takeRunInput(run.id);
if (text)
writeChatDraft(
sessionId,
[draft.prompt, text].filter(Boolean).join("\n"),
draft.images
);
}
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" className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
> >
+1 -1
View File
@@ -508,7 +508,7 @@ export function PromptEditor({
) : ( ) : (
<span <span
title={img.path} title={img.path}
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary" className="flex h-14 w-40 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
> >
<FileText className="h-4 w-4 shrink-0 text-fg-muted" /> <FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{img.name}</span> <span className="truncate">{img.name}</span>
+60 -5
View File
@@ -14,7 +14,7 @@
* (`loadXterm`), not bundled with the Workspace chunk. * (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme * Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching. * (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped on it are saved on the server * Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a * (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads * local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with * a file. The pane also registers with
@@ -25,6 +25,9 @@
* from its terminal — announced by a hint the textarea is described by; * from its terminal — announced by a hint the textarea is described by;
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode * Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's. * (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
* An input method that edits the textarea without composition events (macOS
* Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone
* dropped its capitals and the words it rewrites.
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies * URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
* (OSC 52 — tmux's default `set-clipboard external` passes its own * (OSC 52 — tmux's default `set-clipboard external` passes its own
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser * copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
@@ -37,7 +40,15 @@
* here takes the lead back (`resize` with `drive: true`). * here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react"; import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ClipboardEvent,
type DragEvent,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm"; import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas"; import type { CanvasAddon } from "@xterm/addon-canvas";
@@ -49,6 +60,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme"; import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste"; import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
type DropState = "hint" | "uploading" | "failed"; type DropState = "hint" | "uploading" | "failed";
@@ -184,11 +196,33 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
[] []
); );
const onDrop = async (e: DragEvent) => { // Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const onDrop = (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault(); e.preventDefault();
dragDepth.current = 0; dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files); void attachFiles(Array.from(e.dataTransfer.files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
// text/plain and would paste nothing. Capture phase, because xterm's own
// paste listener stops the event. Text is xterm's to paste, Ctrl+V's too;
// only a Ctrl+V with nothing on the clipboard is the pane's ^V again
// (vim's block select, readline's quoted insert).
const onPaste = (e: ClipboardEvent) => {
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
if (files.length === 0) return; if (files.length === 0) return;
setDrop("uploading"); setDrop("uploading");
try { try {
@@ -327,6 +361,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
requestAnimationFrame(() => findInput.current?.focus()); requestAnimationFrame(() => findInput.current?.focus());
return false; return false;
} }
// Ctrl+V: xterm would send ^V, and claude would then read the
// SERVER's clipboard, never this browser's. Let the browser paste
// instead (onPaste: a file's path, or the text); a browser that fires
// no paste, or one with nothing on the clipboard, still sends ^V.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
ctrlVRef.current = true;
setTimeout(() => {
if (!ctrlVRef.current) return;
ctrlVRef.current = false;
term.input("\x16", true);
}, 0);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false; if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault(); if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true; return true;
@@ -486,6 +533,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const reassert = takeLead; const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert); term.textarea?.addEventListener("focus", reassert);
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
// textarea with no composition events: a capital arrives while Shift is
// held and every later key REPLACES the word — both dropped by xterm
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
const detachIme = attachImeInput(term);
const refit = () => { const refit = () => {
// Never fit a collapsed console — see sendSize. // Never fit a collapsed console — see sendSize.
const el = containerRef.current; const el = containerRef.current;
@@ -514,6 +567,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
resizeObserver.disconnect(); resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded); document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert); term.textarea?.removeEventListener("focus", reassert);
detachIme();
resizeDisposable.dispose(); resizeDisposable.dispose();
dataDisposable.dispose(); dataDisposable.dispose();
ws?.close(); ws?.close();
@@ -634,7 +688,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
dragDepth.current = Math.max(0, dragDepth.current - 1); dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d)); if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}} }}
onDrop={(e) => void onDrop(e)} onDrop={onDrop}
onPasteCapture={onPaste}
onFocus={() => notePaneFocus(runId)} onFocus={() => notePaneFocus(runId)}
> >
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" /> <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
); );
expect(screen.getByText("Parse plan").className).toContain("line-through"); expect(screen.getByText("Parse plan").className).toContain("line-through");
expect(screen.getByText("Build report").className).not.toContain("line-through"); expect(screen.getByText("Build report").className).not.toContain("line-through");
// How far the plan is, and which step is being worked on.
expect(screen.getByText("1/2")).toBeTruthy();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step");
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" })); fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx"); expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
@@ -7,30 +7,52 @@
* activity panel, collapsed into a file button with the output count, whose * activity panel, collapsed into a file button with the output count, whose
* files open a preview in its place; a file in a turn's "Edited N files" card * files open a preview in its place; a file in a turn's "Edited N files" card
* opens in the Workspace's Changes panel when it is in the pane's folder; and * opens in the Workspace's Changes panel when it is in the pane's folder; and
* Stop in Send's place while the main agent works, pressing Esc in the pane. * Stop in Send's place while the main agent works, pressing Esc in the pane;
* and the menu the CLI asks with, shown as buttons that press its keys, a
* message sent meanwhile typed as its "Type something" answer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat } from "../PaneChat"; import { PaneChat, contextWindow } from "../PaneChat";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import { interruptRun, submitToRun } from "../../../lib/panePaste"; import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges"; import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
vi.mock("../../conversation/ConversationView", () => ({ vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file. // Stands in for a turn's "Edited N files" card: a click names a file.
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => ( ConversationView: ({
onOpenFile,
onNotStarted,
}: {
onOpenFile?: (path: string) => void;
onNotStarted?: (notStarted: boolean) => void;
}) => (
<div data-testid="conversation"> <div data-testid="conversation">
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} /> <button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} /> <button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
{/* The session has no row yet: the CLI has not started it. */}
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
</div> </div>
), ),
})); }));
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
vi.mock("../../../lib/panePaste", () => ({ vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true), submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true), interruptRun: vi.fn(() => true),
readRunMenu: vi.fn(() => null),
chooseRunOption: vi.fn(() => true),
answerRunMenu: vi.fn(() => true),
})); }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -126,6 +148,7 @@ beforeEach(() => {
vi.mocked(submitToRun).mockClear(); vi.mocked(submitToRun).mockClear();
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`). // A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
localStorage.removeItem("agenthub.chat.draft.s1"); localStorage.removeItem("agenthub.chat.draft.s1");
sessionStorage.clear();
}); });
describe("PaneChat — the + menu", () => { describe("PaneChat — the + menu", () => {
@@ -200,6 +223,44 @@ describe("PaneChat — Stop", () => {
}); });
}); });
describe("PaneChat — waiting on the user", () => {
it("marks a hidden tab while a permission prompt waits", async () => {
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
document.title = "Workspace · AgentHub";
try {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [
{
type: "main",
status: "waiting",
awaiting_input_since: "2026-10-12T08:00:00.000Z",
awaiting_reason: "notification",
},
],
} as never);
const { unmount } = renderChat();
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
unmount();
expect(document.title).toBe("Workspace · AgentHub");
} finally {
vis.mockRestore();
}
});
});
describe("contextWindow", () => {
it("knows Claude's 200K line from the 1M one", () => {
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
expect(contextWindow("gpt-5-codex")).toBeNull();
expect(contextWindow(null)).toBeNull();
});
});
describe("PaneChat — model, effort and permission mode", () => { describe("PaneChat — model, effort and permission mode", () => {
it("types /model into the pane", async () => { it("types /model into the pane", async () => {
renderChat(); renderChat();
@@ -253,27 +314,63 @@ describe("PaneChat — the activity panel", () => {
expect(screen.getByTestId("chat-outline")).toBeTruthy(); expect(screen.getByTestId("chat-outline")).toBeTruthy();
}); });
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => { it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
const pane = (panelOpen: boolean) => ( const pane = (panelOpen: boolean) => (
<MemoryRouter> <MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} /> <PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
</MemoryRouter> </MemoryRouter>
); );
const { rerender } = render(pane(true)); const { rerender } = render(pane(true));
expect(screen.getByTestId("chat-outline")).toBeTruthy(); await screen.findByTestId("chat-outline");
rerender(pane(false)); rerender(pane(false));
expect(screen.queryByTestId("chat-outline")).toBeNull(); expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show")).toBeTruthy(); expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
}); });
it("resizes from its splitter and remembers the width", () => { it("starts closed while the session has no output, even in a spanning pane", async () => {
vi.mocked(api.sessions.outline).mockResolvedValueOnce({
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
} as never);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled());
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show").textContent).toBe("0");
});
it("keeps the user's open or close across the chat leaving for the terminal", async () => {
const pane = () => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const first = render(pane());
fireEvent.click(
within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" })
);
first.unmount();
render(pane());
await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2"));
expect(screen.queryByTestId("chat-outline")).toBeNull();
});
it("resizes from its splitter and remembers the width", async () => {
localStorage.removeItem("agenthub.chat.panelWidth"); localStorage.removeItem("agenthub.chat.panelWidth");
render( render(
<MemoryRouter> <MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen /> <PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter> </MemoryRouter>
); );
const splitter = screen.getByRole("separator", { name: "Resize side panel" }); const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
expect(splitter.getAttribute("aria-valuenow")).toBe("70"); expect(splitter.getAttribute("aria-valuenow")).toBe("70");
fireEvent.keyDown(splitter, { key: "ArrowLeft" }); fireEvent.keyDown(splitter, { key: "ArrowLeft" });
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35"); expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
@@ -333,4 +430,82 @@ describe("PaneChat — the unsent prompt", () => {
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts"); expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull()); await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
}); });
// Issue #73: before the session starts the CLI may be asking in the terminal
// (trust this folder? — "No, exit" preselected); an Enter sent from here
// would answer it, so a send asks first.
it("asks before sending while the session has not started", async () => {
vi.mocked(submitToRun).mockClear();
confirmDialog.mockClear();
renderChat();
fireEvent.click(screen.getByTestId("not-started"));
const box = screen.getByPlaceholderText("Write a message…");
fireEvent.change(box, { target: { value: "hello" } });
confirmDialog.mockResolvedValueOnce(false);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
expect(submitToRun).not.toHaveBeenCalled();
expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
});
it("sends without asking once the session has started", async () => {
vi.mocked(submitToRun).mockClear();
confirmDialog.mockClear();
renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
expect(confirmDialog).not.toHaveBeenCalled();
});
});
describe("PaneChat — a menu the CLI asks with", () => {
const MENU: PaneMenu = {
question: "Which color?",
options: [
{ n: 1, label: "Red", hint: "warm", checked: null },
{ n: 2, label: "Type something.", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: 1,
};
it("shows the question as buttons in the box's place, and a pick presses its key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
try {
renderChat();
const card = await screen.findByTestId("pane-chat-menu");
expect(card).toHaveTextContent("Which color?");
expect(card).toHaveTextContent("warm");
// The prompt box is hidden (kept mounted) while the CLI asks.
expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull();
fireEvent.click(screen.getByText("Red"));
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0);
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("types the answer in the card's own field, never pressing that row's key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
vi.mocked(chooseRunOption).mockClear();
vi.mocked(submitToRun).mockClear();
try {
renderChat();
fireEvent.click(await screen.findByText("Type something."));
expect(chooseRunOption).not.toHaveBeenCalled();
const field = screen.getByTestId("pane-chat-menu-input");
expect(field).toHaveFocus();
fireEvent.change(field, { target: { value: "purple" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple");
expect(submitToRun).not.toHaveBeenCalled();
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
}); });
@@ -2,7 +2,8 @@
* @file TerminalView.test.tsx * @file TerminalView.test.tsx
* @description Tests for the xterm.js-backed terminal view: verifies it opens * @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the * a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and * mocked terminal, forwards typed input (Safari Telex word replacements
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not * re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close, * after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching. * and recolours live on a dark-theme change without re-attaching.
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn(); const keyHandlerMock = vi.fn();
const focusMock = vi.fn(); const focusMock = vi.fn();
const pasteMock = vi.fn(); const pasteMock = vi.fn();
const inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn(); const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } }, api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
@@ -69,6 +73,10 @@ vi.mock("@xterm/xterm", () => ({
attachCustomKeyEventHandler: keyHandlerMock, attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock, focus: focusMock,
paste: pasteMock, paste: pasteMock,
input: inputMock,
textarea: (textarea = document.createElement("textarea")),
// xterm's own element, holding its textarea (lib/imeInput listens on it).
element: document.createElement("div").appendChild(textarea).parentElement,
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }), resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})), })),
})); }));
@@ -503,4 +511,83 @@ describe("TerminalView", () => {
) )
); );
}); });
it("uploads a pasted image and pastes its path, like a drop", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-image.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => {
vi.useFakeTimers();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
// Not cancelled: xterm's own paste listener takes the text.
expect(
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
})
).toBe(true);
vi.advanceTimersByTime(1);
expect(inputMock).not.toHaveBeenCalled();
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "" },
});
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
vi.useRealTimers();
});
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
vi.useFakeTimers();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
vi.useRealTimers();
});
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
// every later key REPLACES the word — the event order captured on a Mac.
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const type = (value: string) => {
textarea.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertReplacementText" }));
textarea.value = value;
textarea.dispatchEvent(new InputEvent("input", { inputType: "insertReplacementText" }));
};
textarea.value = "x"; // xterm itself sent this one (insertText)
type("xi");
type("xin");
textarea.value = "xin vie";
type("xin viet");
type("xin viêt");
type("xin việt");
expect(inputMock.mock.calls).toEqual([
["i", true],
["n", true],
["t", true],
["\x7f\x7fêt", true],
["\x7f\x7fệt", true],
]);
});
}); });
-1
View File
@@ -43,7 +43,6 @@
"browse.fileTitle": "Browse for a file", "browse.fileTitle": "Browse for a file",
"browse.select": "Select this folder", "browse.select": "Select this folder",
"browse.title": "Browse for a folder", "browse.title": "Browse for a folder",
"browse.typePath": "No native picker is available here — type the path instead.",
"changes.back": "All files", "changes.back": "All files",
"changes.bin": "bin", "changes.bin": "bin",
"changes.binary": "Binary file — not shown.", "changes.binary": "Binary file — not shown.",
+12
View File
@@ -86,6 +86,9 @@
"placeholder": "Write a message…", "placeholder": "Write a message…",
"send": "Send", "send": "Send",
"notSent": "The terminal is not connected — the message was not sent.", "notSent": "The terminal is not connected — the message was not sent.",
"notStarted": "Claude Code may be asking something in the terminal — whether to trust this folder, for example. Sending now presses Enter there, which can answer it (its preselected choice is \"No, exit\"). Open the terminal to answer it first.",
"notStartedTitle": "The session has not started",
"sendAnyway": "Send anyway",
"stop": "Stop", "stop": "Stop",
"notStopped": "The terminal is not connected — nothing was stopped.", "notStopped": "The terminal is not connected — nothing was stopped.",
"add": "Add files, skills…", "add": "Add files, skills…",
@@ -119,6 +122,15 @@
"close": "Close", "close": "Close",
"noPreview": "No preview for this file here", "noPreview": "No preview for this file here",
"truncated": "Showing the first part of this file" "truncated": "Showing the first part of this file"
},
"contextTitle": "Context: {{used}} of {{size}} tokens — Claude Code compacts it when it is nearly full",
"tab": {
"waiting": "Waiting",
"done": "Done"
},
"menu": {
"placeholder": "Type your answer…",
"cancel": "Cancel (Esc)"
} }
}, },
"interrupted": { "interrupted": {
+12 -1
View File
@@ -143,6 +143,8 @@
"loading": "Loading conversation…", "loading": "Loading conversation…",
"empty": "No conversation records found.", "empty": "No conversation records found.",
"emptyBody": "This session's metadata was imported, but its transcript file is no longer on disk. Claude Code automatically deletes inactive session transcripts after a retention period (cleanupPeriodDays, default 30 days), so older conversations may already be gone. Sessions imported from now on are snapshotted and kept even after Claude Code prunes the originals.", "emptyBody": "This session's metadata was imported, but its transcript file is no longer on disk. Claude Code automatically deletes inactive session transcripts after a retention period (cleanupPeriodDays, default 30 days), so older conversations may already be gone. Sessions imported from now on are snapshotted and kept even after Claude Code prunes the originals.",
"notStarted": "The session has not started yet.",
"notStartedBody": "Claude Code may be asking you something in the terminal first — whether to trust this folder, for example. Answer it there; the conversation shows up here once the session starts.",
"newMessages": "New messages", "newMessages": "New messages",
"systemReminder": "System reminder", "systemReminder": "System reminder",
"persistedOutput": "Persisted output", "persistedOutput": "Persisted output",
@@ -199,7 +201,16 @@
}, },
"edited_one": "Edited {{count}} file", "edited_one": "Edited {{count}} file",
"edited_other": "Edited {{count}} files", "edited_other": "Edited {{count}} files",
"working": "Thinking…" "working": "Thinking…",
"turnTokens": "↑ {{tokens}} tokens",
"waiting": "Waiting for you in the terminal",
"waitingFor": "Waiting for your approval: {{step}}",
"openTerminal": "Open terminal",
"moreSteps_one": "+{{count}} more",
"moreSteps_other": "+{{count}} more",
"quiet": "no new activity for {{time}}",
"askDeclined": "Declined — no answer given",
"turnDone": "Done in {{time}}"
}, },
"overview": { "overview": {
"agent": "Agent", "agent": "Agent",
-1
View File
@@ -43,7 +43,6 @@
"browse.fileTitle": "Duyệt chọn tệp", "browse.fileTitle": "Duyệt chọn tệp",
"browse.select": "Chọn thư mục này", "browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục", "browse.title": "Duyệt chọn thư mục",
"browse.typePath": "Không mở được hộp thoại chọn của hệ điều hành ở đây — hãy gõ đường dẫn.",
"changes.back": "Tất cả file", "changes.back": "Tất cả file",
"changes.bin": "bin", "changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.", "changes.binary": "File nhị phân — không hiển thị.",
+12
View File
@@ -86,6 +86,9 @@
"placeholder": "Viết tin nhắn…", "placeholder": "Viết tin nhắn…",
"send": "Gửi", "send": "Gửi",
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.", "notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
"notStarted": "Có thể Claude Code đang hỏi bạn trong terminal — ví dụ có tin tưởng (trust) thư mục này không. Gửi lúc này sẽ nhấn Enter ở đó và có thể trả lời câu hỏi ấy (lựa chọn mặc định là \"No, exit\"). Hãy mở terminal để trả lời trước.",
"notStartedTitle": "Phiên chưa bắt đầu",
"sendAnyway": "Vẫn gửi",
"stop": "Ngưng", "stop": "Ngưng",
"notStopped": "Terminal chưa kết nối — chưa ngưng được.", "notStopped": "Terminal chưa kết nối — chưa ngưng được.",
"add": "Thêm file, skill…", "add": "Thêm file, skill…",
@@ -119,6 +122,15 @@
"close": "Đóng", "close": "Đóng",
"noPreview": "Không xem trước được tệp này ở đây", "noPreview": "Không xem trước được tệp này ở đây",
"truncated": "Chỉ hiện phần đầu của tệp" "truncated": "Chỉ hiện phần đầu của tệp"
},
"contextTitle": "Ngữ cảnh: {{used}} / {{size}} token — gần đầy thì Claude Code tự compact",
"tab": {
"waiting": "Đang chờ",
"done": "Xong"
},
"menu": {
"placeholder": "Nhập câu trả lời của bạn…",
"cancel": "Huỷ (Esc)"
} }
}, },
"interrupted": { "interrupted": {
+12 -1
View File
@@ -143,6 +143,8 @@
"loading": "Đang tải hội thoại…", "loading": "Đang tải hội thoại…",
"empty": "Không tìm thấy bản ghi hội thoại.", "empty": "Không tìm thấy bản ghi hội thoại.",
"emptyBody": "Siêu dữ liệu của phiên này đã được nhập, nhưng tệp transcript không còn trên đĩa. Claude Code tự động xóa transcript của phiên không hoạt động sau một thời gian lưu giữ (cleanupPeriodDays, mặc định 30 ngày), nên các hội thoại cũ có thể đã mất. Các phiên được nhập từ nay sẽ được chụp lại và giữ nguyên ngay cả khi Claude Code xóa bản gốc.", "emptyBody": "Siêu dữ liệu của phiên này đã được nhập, nhưng tệp transcript không còn trên đĩa. Claude Code tự động xóa transcript của phiên không hoạt động sau một thời gian lưu giữ (cleanupPeriodDays, mặc định 30 ngày), nên các hội thoại cũ có thể đã mất. Các phiên được nhập từ nay sẽ được chụp lại và giữ nguyên ngay cả khi Claude Code xóa bản gốc.",
"notStarted": "Phiên chưa bắt đầu.",
"notStartedBody": "Có thể Claude Code đang hỏi bạn trong terminal trước — ví dụ có tin tưởng (trust) thư mục này không. Hãy trả lời ở đó; hội thoại sẽ hiện ở đây khi phiên bắt đầu.",
"newMessages": "Tin nhắn mới", "newMessages": "Tin nhắn mới",
"systemReminder": "Nhắc nhở hệ thống", "systemReminder": "Nhắc nhở hệ thống",
"persistedOutput": "Đầu ra đã lưu", "persistedOutput": "Đầu ra đã lưu",
@@ -199,7 +201,16 @@
}, },
"edited_one": "Đã sửa {{count}} tệp", "edited_one": "Đã sửa {{count}} tệp",
"edited_other": "Đã sửa {{count}} tệp", "edited_other": "Đã sửa {{count}} tệp",
"working": "Đang suy nghĩ…" "working": "Đang suy nghĩ…",
"turnTokens": "↑ {{tokens}} token",
"waiting": "Đang chờ bạn trong terminal",
"waitingFor": "Đang chờ bạn cho phép: {{step}}",
"openTerminal": "Mở terminal",
"moreSteps_one": "+{{count}} việc nữa",
"moreSteps_other": "+{{count}} việc nữa",
"quiet": "không có hoạt động mới trong {{time}}",
"askDeclined": "Đã từ chối — không trả lời",
"turnDone": "Xong trong {{time}}"
}, },
"overview": { "overview": {
"agent": "Agent", "agent": "Agent",
+25
View File
@@ -534,6 +534,31 @@
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
/* The chat's live step, like Claude Code's: a light sweeping over the words.
Reduced motion stops it on the muted colour. */
.text-shimmer {
background: linear-gradient(
90deg,
rgb(var(--fg-muted)) 40%,
rgb(var(--fg-primary)) 50%,
rgb(var(--fg-muted)) 60%
)
0 0 / 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: text-shimmer 2.5s linear infinite;
}
@keyframes text-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0 0;
}
}
/* Rendered markdown (MarkdownPreview.tsx): theme tokens only, so every theme /* Rendered markdown (MarkdownPreview.tsx): theme tokens only, so every theme
reads it; no typography plugin. */ reads it; no typography plugin. */
.md-preview { .md-preview {
+159
View File
@@ -0,0 +1,159 @@
/**
* @file imeInput.test.ts
* @description Pins `attachImeInput` against a real event trace of macOS's
* Vietnamese Simple Telex input method in Chrome (typing "Abc xin chào"): the
* IME writes into xterm's textarea with `insertText` / `insertReplacementText`
* and no composition events, its keydown (keyCode 229) arriving AFTER the
* change — which stock xterm 5.5 turned into " x c". The pane must receive
* exactly the text typed, a word the IME rewrites as backspaces plus the new
* tail, and keys xterm already sent from `keypress` (Space) never twice.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, describe, expect, it } from "vitest";
import { attachImeInput, textEdit } from "../imeInput";
type Step = [type: string, a: string, b: string, keyCode?: number, shift?: boolean];
// Recorded in the user's browser, one line per event on the textarea.
const TELEX: Step[] = [
["keydown", "Shift", "ShiftLeft", 16, true],
["input", "A", "insertText"],
["keydown", "A", "KeyA", 229, true],
["keyup", "A", "KeyA", 65, true],
["keyup", "Shift", "ShiftLeft", 16],
["input", "Ab", "insertReplacementText"],
["keydown", "b", "KeyB", 229],
["keyup", "b", "KeyB", 66],
["input", "Abc", "insertReplacementText"],
["keydown", "c", "KeyC", 229],
["keyup", "c", "KeyC", 67],
["keydown", " ", "Space", 32],
["keypress", " ", "Space", 32],
["input", " ", "insertText"],
["keyup", " ", "Space", 32],
["input", "x", "insertText"],
["keydown", "x", "KeyX", 229],
["keyup", "x", "KeyX", 88],
["input", "xi", "insertReplacementText"],
["keydown", "i", "KeyI", 229],
["input", "xin", "insertReplacementText"],
["keydown", "n", "KeyN", 229],
["keydown", " ", "Space", 32],
["keypress", " ", "Space", 32],
["input", " ", "insertText"],
["input", "c", "insertText"],
["keydown", "c", "KeyC", 229],
["input", "ch", "insertReplacementText"],
["keydown", "h", "KeyH", 229],
["input", "cha", "insertReplacementText"],
["keydown", "a", "KeyA", 229],
["input", "chao", "insertReplacementText"],
["keydown", "o", "KeyO", 229],
["input", "chào", "insertReplacementText"],
["keydown", "f", "KeyF", 229],
];
let cleanup: (() => void) | null = null;
afterEach(() => {
cleanup?.();
cleanup = null;
document.body.innerHTML = "";
});
/** A terminal stand-in: xterm's element and textarea, `input` collecting what
* reaches the pane, and `xterm` what xterm's own textarea listeners saw. */
function setup() {
const element = document.createElement("div");
const textarea = document.createElement("textarea");
element.appendChild(textarea);
document.body.appendChild(element);
let sent = "";
const xterm: string[] = [];
for (const t of ["keydown", "keypress", "input"])
textarea.addEventListener(t, (e) => xterm.push(`${t}:${(e as KeyboardEvent).key ?? ""}`), true);
// xterm sends a printable key from its keypress handler (Space, here).
textarea.addEventListener("keypress", (e) => (sent += e.key), true);
const term = { element, textarea, input: (d: string) => (sent += d) };
cleanup = attachImeInput(term);
return { textarea, xterm, sent: () => sent };
}
function play(ta: HTMLTextAreaElement, steps: Step[]) {
for (const [type, a, b, kc, shift] of steps) {
if (type === "input") {
const before = ta.value;
const next = b === "insertText" ? before + a : before.replace(/\S*$/, "") + a;
ta.dispatchEvent(new InputEvent("beforeinput", { data: a, inputType: b, bubbles: true }));
ta.value = next;
ta.dispatchEvent(new InputEvent("input", { data: a, inputType: b, bubbles: true }));
} else {
const ev = new KeyboardEvent(type, { key: a, code: b, shiftKey: !!shift, bubbles: true });
Object.defineProperty(ev, "keyCode", { get: () => kc });
ta.dispatchEvent(ev);
}
}
}
/** What a line editor holds after `data` (DEL erases one character). */
const typed = (data: string) => {
const out: string[] = [];
for (const ch of data) {
if (ch === "\x7f") out.pop();
else out.push(ch);
}
return out.join("");
};
describe("textEdit", () => {
it("is the backspaces and new tail that turn one value into the other", () => {
expect(textEdit("Abc xin cha", "Abc xin chao")).toBe("o");
expect(textEdit("Abc xin chao", "Abc xin chào")).toBe("\x7f\x7fào");
expect(textEdit("x", "x")).toBe("");
expect(textEdit("ab", "")).toBe("\x7f\x7f");
});
it("counts characters, not UTF-16 units", () => {
expect(textEdit("a😀", "a")).toBe("\x7f");
});
});
describe("attachImeInput", () => {
it("sends what Telex typed — capitals and rewritten words included", () => {
const { textarea, sent } = setup();
play(textarea, TELEX);
expect(typed(sent())).toBe("Abc xin chào");
});
it("leaves xterm the keys it sends itself, and hides the IME's from it", () => {
const { textarea, xterm, sent } = setup();
play(textarea, TELEX);
// Space is xterm's (its keypress sends it, its input event follows); the
// 229 keydowns and the IME's own input events never reach it.
expect(xterm.filter((x) => x.startsWith("keypress"))).toHaveLength(2);
expect(xterm.filter((x) => x.startsWith("input"))).toHaveLength(2);
expect(xterm.some((x) => x === "keydown:A" || x === "keydown:b")).toBe(false);
expect(sent().split(" ")).toHaveLength(3);
});
it("does not touch a composition (CJK input) — xterm handles that", () => {
const { textarea, xterm, sent } = setup();
const ev = new InputEvent("input", {
data: "に",
inputType: "insertCompositionText",
bubbles: true,
});
textarea.value = "に";
textarea.dispatchEvent(ev);
expect(sent()).toBe("");
expect(xterm).toContain("input:");
});
it("stops after dispose", () => {
const { textarea, sent } = setup();
cleanup?.();
cleanup = null;
play(textarea, TELEX.slice(0, 2));
expect(sent()).toBe("");
});
});
+190 -2
View File
@@ -6,16 +6,22 @@
* is not on screen (a hidden console behind a full-width Changes panel); and * is not on screen (a hidden console behind a full-width Changes panel); and
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane; * `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane;
* and the input box read off a real `claude`/`codex` screen (`parsePromptInput`) * and the input box read off a real `claude`/`codex` screen (`parsePromptInput`)
* and moved out of it (`takeRunInput`) when the pane switches to Chat. * and moved out of it (`takeRunInput`) when the pane switches to Chat; and
* the menu a CLI asks with, read off real Claude Code 2.1.296 / Codex 0.161
* screens (`parsePaneMenu`) and answered with the keys verified there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
interruptRun, interruptRun,
notePaneFocus, notePaneFocus,
parsePromptInput, parsePromptInput,
answerRunMenu,
chooseRunOption,
parsePaneMenu,
pasteToLane, pasteToLane,
readRunMenu,
registerPane, registerPane,
takeRunInput, takeRunInput,
} from "../panePaste"; } from "../panePaste";
@@ -122,4 +128,186 @@ describe("panePaste", () => {
off(); off();
expect(takeRunInput("agenthub-lane-7")).toBe(""); expect(takeRunInput("agenthub-lane-7")).toBe("");
}); });
it("reads an AskUserQuestion menu: question, options, hints, cursor", () => {
const m = parsePaneMenu(ASK);
expect(m).toEqual({
question: "Which color do you like: red, green, or blue?",
options: [
{ n: 1, label: "Red", hint: "Crisp red", checked: null },
{ n: 2, label: "Green", hint: "", checked: null },
{ n: 3, label: "Type something.", hint: "", checked: null },
{ n: 4, label: "Chat about this", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: 2,
});
});
it("reads a multiSelect question with its unnumbered Submit row", () => {
const m = parsePaneMenu(MULTI)!;
expect(m.multi).toBe(true);
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
[1, "Nuts", true],
[2, "Honey", false],
[3, "Type something", false],
[null, "Submit", null],
[4, "Chat about this", null],
]);
expect(m.cursor).toBe(1);
expect(m.typeAt).toBe(2);
});
// The "Type something" row once something is typed in it: its label is the
// text. It is still the row right above "Chat about this".
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
vi.useFakeTimers();
const keys: string[] = [];
const typed = ASK.map((r) =>
r === "❯ 1. Red" ? " 1. Red" : r === " 3. Type something." ? "❯ 3. 44444" : r
);
const m = parsePaneMenu(typed)!;
expect(m.typeAt).toBe(2);
expect(m.cursor).toBe(2);
const off = registerPane(
"agenthub-lane-9",
() => {},
() => true,
(d) => keys.push(d)
);
expect(answerRunMenu("agenthub-lane-9", m, "teal")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x15", "\x1b[200~teal\x1b[201~", "\r"]);
off();
vi.useRealTimers();
});
it("reads Claude's and Codex's permission prompts", () => {
const c = parsePaneMenu(PERMISSION)!;
expect(c.question).toBe("Do you want to proceed?");
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
const x = parsePaneMenu(CODEX_APPROVAL)!;
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
expect(x.options).toHaveLength(3);
expect(x.cursor).toBe(0);
});
it("reads a menu with no footer: the login method at first start", () => {
const m = parsePaneMenu([
" Select login method:",
"",
" ❯ 1. Claude account with subscription · Pro, Max, Team, or Enterprise",
"",
" 2. Anthropic Console account · API usage billing",
"",
])!;
expect(m.question).toBe("Select login method:");
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
});
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
// No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
// A numbered list typed into the input box (under its rule), mid-turn.
expect(parsePaneMenu([RULE, "❯ 1. fix it", RULE, " esc to interrupt"])).toBeNull();
expect(parsePaneMenu([RULE, "❯\u00a01. a", " 2. b", RULE, " esc to interrupt"])).toBeNull();
expect(parsePaneMenu(CLAUDE)).toBeNull();
});
it("answers by digit, walks to Submit, and types into Type something", () => {
vi.useFakeTimers();
const keys: string[] = [];
let rows = ASK;
const off = registerPane(
"agenthub-lane-8",
() => {},
() => true,
(d) => keys.push(d),
() => ({ rows, cols: 80 })
);
const ask = readRunMenu("agenthub-lane-8")!;
expect(chooseRunOption("agenthub-lane-8", ask, 1)).toBe(true);
expect(keys).toEqual(["2"]);
keys.length = 0;
// Single select: walk to the row, paste, Enter.
expect(answerRunMenu("agenthub-lane-8", ask, "purple")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\x1b[200~purple\x1b[201~", "\r"]);
keys.length = 0;
rows = MULTI;
const multi = readRunMenu("agenthub-lane-8")!;
// From Honey (cursor 1) to Submit (3): two Downs, then Enter.
expect(chooseRunOption("agenthub-lane-8", multi, 3)).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\r"]);
keys.length = 0;
// multiSelect: the typed text ticks the row; no Enter, Submit still to pick.
expect(answerRunMenu("agenthub-lane-8", multi, "salt")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[200~salt\x1b[201~"]);
off();
vi.useRealTimers();
expect(readRunMenu("agenthub-lane-8")).toBeNull();
});
}); });
// Real screens (descriptions trimmed), bottom of a 120-column pane.
const ASK = [
"❯ Use the AskUserQuestion tool to ask me which color I like.",
RULE,
" ☐ Color",
"",
"Which color do you like: red, green, or blue?",
"",
"❯ 1. Red",
" Crisp red",
" 2. Green",
" Green",
" 3. Type something.",
RULE,
" 4. Chat about this",
"",
"Enter to select · ↑/↓ to navigate · Esc to cancel",
"",
];
const MULTI = [
RULE,
"← ☒ Fruit ☐ Toppings ✔ Submit →",
"",
"Which toppings do you want?",
"",
" 1. [✔] Nuts",
" Crunchy, adds texture.",
"❯ 2. [ ] Honey",
" 3. [ ] Type something",
" Submit",
RULE,
" 4. Chat about this",
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const PERMISSION = [
" Bash command",
" touch probe-file.txt",
"╌".repeat(60),
" Do you want to proceed?",
" ❯ 1. Yes",
" 2. Yes, and always allow",
" 3. No",
"",
" Esc to cancel · Tab to amend",
];
const CODEX_APPROVAL = [
"• Running touch probe.txt",
"",
" Would you like to run the following command?",
" $ touch probe.txt",
"",
"› 1. Yes, proceed (y)",
" 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p)",
" 3. No, and tell Codex what to do differently",
"",
// "Press enter to confirm or esc to cancel", its dim cells left out.
" enteresc",
];
+10 -4
View File
@@ -2970,14 +2970,20 @@ export const api = {
* the Add Lane modal's folder browser. Browsers cannot expose absolute * the Add Lane modal's folder browser. Browsers cannot expose absolute
* filesystem paths from a native picker, so this drives a server-backed one. * filesystem paths from a native picker, so this drives a server-backed one.
* @param path Absolute path to list; defaults server-side to the home dir. * @param path Absolute path to list; defaults server-side to the home dir.
* @param files List files too (`file: true`) — a file field's picker.
* @returns `{ path, parent, entries }` — `parent` is null at the root. * @returns `{ path, parent, entries }` — `parent` is null at the root.
*/ */
browse: (path?: string) => browse: (path?: string, files?: boolean) => {
request<{ const q = new URLSearchParams();
if (path) q.set("path", path);
if (files) q.set("files", "1");
const qs = q.toString();
return request<{
path: string; path: string;
parent: string | null; parent: string | null;
entries: { name: string; path: string; isGitRepo: boolean }[]; entries: { name: string; path: string; isGitRepo: boolean; file?: boolean }[];
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`), }>(`/lanes/browse${qs ? `?${qs}` : ""}`);
},
/** /**
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the * POST /api/lanes/pick-path — open the OS's own folder/file picker on the
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a * server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
+90
View File
@@ -0,0 +1,90 @@
/**
* @file imeInput.ts
* @description Typing through an input method that edits xterm's textarea
* without composition events — macOS's Vietnamese Telex, in Chrome and
* Safari, writes each letter with `insertText` and rewrites the whole word
* with `insertReplacementText` ("cha" → "chao" → "chào"), its keydown
* (keyCode 229) arriving only AFTER the change. xterm 5.5 drops every
* replacement (xterm.js #5887) and every `input` that lands while a key is
* held (Shift, for a capital), so "Abc xin chào" reached the pane as " x c".
* `attachImeInput` turns each such change into what a line editor needs — a
* backspace per replaced character, then the new tail — and hides those
* events from xterm so nothing is sent twice. A key xterm sends itself (its
* `keypress`, e.g. Space) and a real composition (CJK) stay xterm's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** DEL, the byte a terminal's Backspace sends (xterm's too). */
const DEL = "\x7f";
/** The edits this module answers; anything else stays xterm's. */
const EDITS = new Set(["insertText", "insertReplacementText", "deleteContentBackward"]);
/** The backspaces and new tail that turn `before` into `after` — per
* character (code point), as the pane's line editor counts them. */
export function textEdit(before: string, after: string): string {
const a = Array.from(before);
const b = Array.from(after);
let same = 0;
while (same < a.length && same < b.length && a[same] === b[same]) same++;
return DEL.repeat(a.length - same) + b.slice(same).join("");
}
/** The parts of an xterm `Terminal` this needs. */
interface ImeTerminal {
element?: HTMLElement;
textarea?: HTMLTextAreaElement;
input: (data: string, wasUserInput?: boolean) => void;
}
/** Sends `term`'s non-composition textarea edits to the pane itself; returns
* its dispose. Listens in the capture phase on xterm's element, so it runs
* before xterm's own listeners on the textarea and can keep them from it. */
export function attachImeInput(term: ImeTerminal): () => void {
const root = term.element;
const ta = term.textarea;
if (!root || !ta) return () => {};
// The value before the edit in flight (from `beforeinput`).
let before: string | null = null;
// xterm sent this key from its keypress: the input event that follows is its.
let pressed = false;
const onKeyDown = (e: KeyboardEvent) => {
if (e.target !== ta) return;
pressed = false;
// The IME's own key, its edit already in (or about to come as) an input
// event: xterm's fallback would diff the textarea and send it again.
if (e.keyCode === 229 && !e.isComposing) e.stopPropagation();
};
const onKeyPress = (e: KeyboardEvent) => {
if (e.target === ta) pressed = true;
};
const onBeforeInput = (e: Event) => {
if (e.target === ta) before = ta.value;
};
const onInput = (e: Event) => {
const ev = e as InputEvent;
if (ev.target !== ta) return;
const was = before;
before = null;
if (pressed) {
pressed = false;
return;
}
if (ev.isComposing || !EDITS.has(ev.inputType) || was === null) return;
e.stopPropagation();
const data = textEdit(was, ta.value);
if (data) term.input(data, true);
};
root.addEventListener("keydown", onKeyDown, true);
root.addEventListener("keypress", onKeyPress, true);
root.addEventListener("beforeinput", onBeforeInput, true);
root.addEventListener("input", onInput, true);
return () => {
root.removeEventListener("keydown", onKeyDown, true);
root.removeEventListener("keypress", onKeyPress, true);
root.removeEventListener("beforeinput", onBeforeInput, true);
root.removeEventListener("input", onInput, true);
};
}
+141
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's * terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back * input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side. * without Enter), so the half-typed text is never left behind on the other side.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -151,3 +154,141 @@ export function takeRunInput(runId: string): string {
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n)); p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
return text; return text;
} }
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
* unnumbered Submit row), `checked` a multiSelect box's state. */
export type PaneMenuOption = {
n: number | null;
label: string;
hint: string;
checked: boolean | null;
};
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
* "Type something" row (-1: none). */
export type PaneMenu = {
question: string;
options: PaneMenuOption[];
cursor: number;
multi: boolean;
typeAt: number;
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
const PLACEHOLDER = /^Type something\.?$/;
/** The numbered menu at the bottom of `rows`, or null. A menu has two
* numbered rows or more from 1, the cursor on one of them, and no rule right
* above its first row — a reply's numbered list has no cursor, and a list
* typed into Claude's input box sits under the box's rule. */
export function parsePaneMenu(rows: string[]): PaneMenu | null {
const r = rows.map((l) => l.trimEnd());
let end = r.length - 1;
while (end >= 0 && !r[end]) end--;
const options: PaneMenuOption[] = [];
let cursor = -1;
let hint: string[] = [];
let top = -1;
// Bottom up: a row under an option that is no option is its hint.
for (let i = end; i >= 0 && end - i < 40; i--) {
const row = r[i] ?? "";
const m = OPTION.exec(row);
const s = m ? null : SUBMIT.exec(row);
if (m || s) {
const text = m ? (m[3] ?? "") : "Submit";
const c = m ? CHECK.exec(text) : null;
const label = c ? (c[2] ?? "") : text;
const h = hint.join(" ");
options.unshift({
n: m ? Number(m[2]) : null,
label,
hint: h === label ? "" : h,
checked: c ? c[1] === "✔" : null,
});
if (m?.[1] || s?.[1]) cursor = options.length;
hint = [];
if (m?.[2] === "1") {
top = i;
break;
}
} else if (row && !RULE_ROW.test(row) && options.length) {
hint.unshift(row.trim());
} else if (row && !RULE_ROW.test(row)) {
hint = []; // the footer and anything under the last option
}
}
const numbered = options.filter((o) => o.n !== null);
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
if (numbered.some((o, i) => o.n !== i + 1)) return null;
// The question: the block of text right above the first option.
let q = top - 1;
while (q >= 0 && !r[q]) q--;
const lines: string[] = [];
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
lines.unshift(r[q]!.trim());
return {
question: lines.join("\n"),
options,
cursor: options.length - cursor,
multi: options.some((o) => o.checked !== null),
typeAt: typeRow(options),
};
}
/** The "Type something" row. Text typed into it replaces its label, and it is
* still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number {
const at = options.findIndex((o) => PLACEHOLDER.test(o.label));
if (at >= 0) return at;
for (let i = options.findIndex((o) => o.label === "Chat about this") - 1; i >= 0; i--)
if (options[i]!.n !== null) return i;
return -1;
}
/** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId);
const screen = p?.visible() && p.key ? p.screen?.() : null;
return screen ? parsePaneMenu(screen.rows) : null;
}
// One key per write, a beat apart: a TUI reading two arrows in one chunk may
// take them as one key.
function pressKeys(key: (data: string) => void, keys: string[]) {
keys.forEach((k, i) => setTimeout(() => key(k), i * 40));
}
/** The arrow presses from the menu's cursor to row `to`. */
const walk = (menu: PaneMenu, to: number) =>
Array<string>(Math.abs(to - menu.cursor)).fill(to > menu.cursor ? "\x1b[B" : "\x1b[A");
/** Picks `menu`'s option `index` in `runId`'s pane: its digit — which picks
* it, or ticks its box in a multiSelect — else (Submit) the arrows to it
* and Enter. False when the pane is not visible or not attached. */
export function chooseRunOption(runId: string, menu: PaneMenu, index: number): boolean {
const p = panes.get(runId);
const opt = menu.options[index];
if (!p?.visible() || !p.key || !opt) return false;
if (opt.n !== null && opt.n < 10) p.key(String(opt.n));
else pressKeys(p.key, [...walk(menu, index), "\r"]);
return true;
}
/** Answers `menu` with typed text: the arrows to its "Type something" row,
* Ctrl+U over what is typed there already (one key clears it, however long
* it is), the text as a bracketed
* paste, and Enter — none in a multiSelect, where the text only ticks its
* row and Submit is still to pick. */
export function answerRunMenu(runId: string, menu: PaneMenu, text: string): boolean {
const p = panes.get(runId);
const row = menu.options[menu.typeAt];
if (!p?.visible() || !p.key || !row) return false;
const keys = walk(menu, menu.typeAt);
if (!PLACEHOLDER.test(row.label)) keys.push("\x15");
keys.push(`\x1b[200~${text}\x1b[201~`);
pressKeys(p.key, menu.multi ? keys : [...keys, "\r"]);
return true;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* @file tabBadge.ts — the browser tab's title mark for Chat panes while the
* page is hidden: "● Waiting" while a pane's CLI asks the user something (a
* permission prompt), else "✓ Done" once a turn ended. Shown again, the
* page drops the "Done" marks; a "Waiting" one lasts until its question is
* answered. Only the mark this module put in front of the title is ever
* removed, so a page that sets its own title meanwhile keeps it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import i18n from "../i18n";
export type TabMark = "waiting" | "done";
const marks = new Map<string, TabMark>();
let applied = "";
function apply() {
const title = document.title;
const base = applied && title.startsWith(applied) ? title.slice(applied.length) : title;
const all = [...marks.values()];
const mark =
document.visibilityState !== "hidden"
? null
: all.includes("waiting")
? "waiting"
: all.includes("done")
? "done"
: null;
applied = mark ? `${mark === "waiting" ? "●" : "✓"} ${i18n.t(`run:chat.tab.${mark}`)} · ` : "";
document.title = applied + base;
}
/** Sets (or, with null, drops) the mark of one pane (`key`). */
export function setTabBadge(key: string, mark: TabMark | null): void {
// A turn ending in front of the user needs no mark.
if (mark === "done" && document.visibilityState !== "hidden") mark = null;
if (mark) marks.set(key, mark);
else marks.delete(key);
apply();
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") {
for (const [key, mark] of marks) if (mark === "done") marks.delete(key);
}
apply();
});
}
+3
View File
@@ -3213,6 +3213,9 @@ export interface TranscriptContent {
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and /** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */ * removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number }; diff?: { added: number; removed: number };
/** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */
answers?: Record<string, string>;
} }
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the /** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
+5 -1
View File
@@ -88,6 +88,7 @@ import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format"; import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types"; import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types"; import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -183,14 +184,17 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// Load remote-source labels so remote-origin rows can show a friendly badge // Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes. // instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions();
const loadSourceLabels = useCallback(() => { const loadSourceLabels = useCallback(() => {
if (!seesAll) return;
api.remoteSources api.remoteSources
.list() .list()
.then((res: { sources: RemoteSource[] }) => { .then((res: { sources: RemoteSource[] }) => {
setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label]))); setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label])));
}) })
.catch(() => undefined); .catch(() => undefined);
}, []); }, [seesAll]);
useEffect(() => { useEffect(() => {
loadSourceLabels(); loadSourceLabels();
+7 -5
View File
@@ -196,7 +196,7 @@ export function Settings({
section?: "data" | "about" | "notifications"; section?: "data" | "about" | "notifications";
} = {}) { } = {}) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { isAdmin } = usePermissions(); const { isAdmin, seesAll } = usePermissions();
// Only Data and About show server info; General and Notifications never read it. // Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about"; const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo); const [loading, setLoading] = useState(needsInfo);
@@ -954,10 +954,12 @@ export function Settings({
<ImportHistory /> <ImportHistory />
</section> </section>
{/* ─── REMOTE DATA SOURCES ─── */} {/* ─── REMOTE DATA SOURCES (staff only: the server answers 403 to a developer) ─── */}
<section id="remote-sources" className="scroll-mt-24"> {seesAll && (
<RemoteSources /> <section id="remote-sources" className="scroll-mt-24">
</section> <RemoteSources />
</section>
)}
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */} {/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings /> <PushIngestSettings />
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.7.2 (rebuild client to match) UI build v2.7.5 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+11 -7
View File
@@ -229,11 +229,13 @@ guarded, and only a socket from loopback carrying no `X-Forwarded-For` /
`403 EREMOTE` otherwise, `501 ENODIALOG` with no graphical session, no picker `403 EREMOTE` otherwise, `501 ENODIALOG` with no graphical session, no picker
program, or one that could not reach the display, `409 EBUSY` while one is program, or one that could not reach the display, `409 EBUSY` while one is
already open, `500 EDIALOG` when the program failed. The dialog is closed when already open, `500 EDIALOG` when the program failed. The dialog is closed when
the request goes away (a closed tab, an SSH tunnel's browser leaving). A folder the request goes away (a closed tab, an SSH tunnel's browser leaving). A
**Browse** button in the UI (`client/src/components/PathPickButton.tsx`) falls **Browse** button in the UI (`client/src/components/PathPickButton.tsx`) falls
back on ANY of those to the in-app folder browser back on ANY of those to the in-app browser `GET /api/lanes/browse?path=<abs>`.
`GET /api/lanes/browse?path=<abs>`; a file field falls back to typing the path A file field asks it with `files=1`: entries then include the folder's files
on `EREMOTE`/`ENODIALOG` and shows the error otherwise. In server mode with (`file: true`, listed after the folders), and `path` opens the way the native
picker's `start` does (`~` expanded, a file's folder, home when missing) —
a click on a file picks it. In server mode with
`AGENTHUB_USERS_ROOT` set, a developer's browser opens on their own folder `AGENTHUB_USERS_ROOT` set, a developer's browser opens on their own folder
(`parent: null` there) and any path whose real path leaves it is `403 EFOLDER`. (`parent: null` there) and any path whose real path leaves it is `403 EFOLDER`.
@@ -357,6 +359,8 @@ Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB. Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused. Server mode with one Linux user per account: a missing folder inside a member's own home is created instead, as that member (`mkdir -p` through `write-as`) — their home itself can be no workspace, so this is how a new member's first project folder comes to exist.
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently. Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive workspace action #### Preflight a destructive workspace action
@@ -1599,7 +1603,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
GET /api/sessions/:id/image?path=<absolute path> GET /api/sessions/:id/image?path=<absolute path>
``` ```
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` in server mode and for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read. The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read, and in server mode without S3. With S3 a member's session (its folder under the users root) gets only a file inside that member's home that the member owns (else 404), a host session any file — a member's hook can only report sessions in its own home.
```bash ```bash
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png" curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
@@ -2600,8 +2604,8 @@ GET /api/run/interrupted {items} — sessions whose pane died under
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu) GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); only a name drop-image/drop-file made → 404 otherwise GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it) GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor) (skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
+7 -5
View File
File diff suppressed because one or more lines are too long
+35 -1
View File
@@ -801,7 +801,41 @@ agenthub users provision alice # the Linux account only (a user made before
v2.7.2 on it leaves any run and workspace under the users root (read from v2.7.2 on it leaves any run and workspace under the users root (read from
`/etc/agenthub/server.conf`) alone; a build older than v2.7.2 marks their `/etc/agenthub/server.conf`) alone; a build older than v2.7.2 marks their
runs interrupted or abandoned within seconds, and the proxy gateway then runs interrupted or abandoned within seconds, and the proxy gateway then
refuses those panes (`401 not a live pane's token`). refuses those panes (`401 not a live pane's token`). A dev server that DOES
run in server mode (it read `AGENTHUB_MODE=server` from
`~/.config/agenthub/settings.env`) but lacks the unit's
`CAP_DAC_READ_SEARCH` cannot stat a member's 0700 `.agenthub/tmux`; from
v2.7.3 on that `EACCES` counts as "maybe running" and the member's list is
asked through sudo, never read as "no panes". And a run younger than 20 s
is never ended by any server: only the server that started a pane writes it
into its own cached tmux list, so another one on the same database had not
listed it yet and ended a Codex pane seconds after its start.
- **Update now** under the system unit restarts through
`sudo -n systemctl restart --no-block agenthub.service` (from v2.7.3). An
older build started a detached replacement instead, which kept the port
while `Restart=always` started the unit every few seconds, each start exiting
"already running" — `systemctl status agenthub.service` shows
`activating (auto-restart)` with `MainPID` 0. Recover with
`kill -TERM <pid of the server on the port>`; the unit then starts on the
new code.
- A member's tmux list is re-asked at most every 5 s (one `sudo` each, which
writes three `auth.log` lines); a pane the dashboard starts or stops shows at
once, one that exits on its own within those 5 s.
- Claude Code does not carry a folder's trust down from the member's home
(which provisioning trusts): a member may be asked "Trust this folder" once
per workspace, in the terminal. AgentHub never answers it for them — it is
what keeps a cloned repository's own settings and hooks from running unasked.
- A member's first project folder: a workspace named on a folder that does not
exist yet inside their home is created at its first start, AS the member
(from v2.7.4; their home itself is no workspace).
- Bypass in a pane's shift+tab cycle (`--allow-dangerously-skip-permissions`)
is offered only to an account that already accepted Claude Code's bypass
warning (`skipDangerousModePermissionPrompt` in its `~/.claude/settings.json`)
or to a run that starts in bypass itself. Before v2.7.4 every new member's
first session opened on that warning, which the Chat UI cannot answer.
- A developer's System page (`GET /api/settings/info`, `GET /api/updates/status`)
carries no path: not the database's, the hooks file's, the install folder,
nor the transcript cache's keys — those are every user's transcript paths.
### Undo and uninstall ### Undo and uninstall
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.7.2 version: 2.7.5
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.7.2", "version": "2.7.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.7.2", "version": "2.7.5",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.7.2", "version": "2.7.5",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+10 -2
View File
@@ -16,7 +16,9 @@
* setting never writes the developer's real `~/.config/agenthub/settings.env`, * setting never writes the developer's real `~/.config/agenthub/settings.env`,
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network. The * and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network. The
* `GIT_DIR`-family variables a git hook exports are dropped too, so a test's * `GIT_DIR`-family variables a git hook exports are dropped too, so a test's
* own `git init`/`commit` can never land in the real repository. * own `git init`/`commit` can never land in the real repository. Every
* inherited `AGENTHUB_*` variable is dropped as well: a dashboard pane carries
* `AGENTHUB_MODE=server` and friends, which turned the suites login-gated.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -34,8 +36,14 @@ const files = fs
const args = process.argv.slice(2); const args = process.argv.slice(2);
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-")); const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
// A dashboard pane's own AGENTHUB_* (AGENTHUB_MODE=server, AGENTHUB_USERS_ROOT,
// its pane token…) would turn the suites into a login-gated server: hundreds
// of 401s and EFOLDERs that have nothing to do with the code under test.
const inherited = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("AGENTHUB_"))
);
const env = { const env = {
...process.env, ...inherited,
TMUX_TMPDIR: tmuxDir, TMUX_TMPDIR: tmuxDir,
// Never the developer's real ~/.config/agenthub/settings.env: it can pin // Never the developer's real ~/.config/agenthub/settings.env: it can pin
// DASHBOARD_DATA_DIR at their live database. // DASHBOARD_DATA_DIR at their live database.
+1 -1
View File
@@ -764,7 +764,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) | | `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type | | `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers | | `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
| `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it | | `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image; an admin's GET without `?laneId=` also tries the HOST's drop dir, where their host-folder workspaces drop); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it |
| `GET` | `/api/run/:id/permission-mode` | A live **claude** pane's permission mode, read off the mode line Claude Code prints under its prompt (`server/lib/pane-mode.js`): `{ mode }` — `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`, `dontAsk`, or `null` while a menu hides it. 404 for a pane that is not running, 400 `EUNSUPPORTED` for a non-claude pane | | `GET` | `/api/run/:id/permission-mode` | A live **claude** pane's permission mode, read off the mode line Claude Code prints under its prompt (`server/lib/pane-mode.js`): `{ mode }` — `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`, `dontAsk`, or `null` while a menu hides it. 404 for a pane that is not running, 400 `EUNSUPPORTED` for a non-claude pane |
| `POST` | `/api/run/:id/permission-mode` | `{ mode }` → steps the pane there with Shift+Tab, the only way a running session changes mode, reading every step back before the next key. `{ mode }` on success; 400 `EBADMODE` outside the five settable modes; 409 `EMODEUNKNOWN` (no mode line — nothing is pressed — or a step did not change it), `EMODEUNAVAILABLE` (not in this session's cycle, e.g. Bypass in a session not started with it; the pane is back on its starting mode), `EBUSY` (a change is already running on that pane) | | `POST` | `/api/run/:id/permission-mode` | `{ mode }` → steps the pane there with Shift+Tab, the only way a running session changes mode, reading every step back before the next key. `{ mode }` on success; 400 `EBADMODE` outside the five settable modes; 409 `EMODEUNKNOWN` (no mode line — nothing is pressed — or a step did not change it), `EMODEUNAVAILABLE` (not in this session's cycle, e.g. Bypass in a session not started with it; the pane is back on its starting mode), `EBUSY` (a change is already running on that pane) |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match | | `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
+18 -1
View File
@@ -22,8 +22,17 @@ before(() => {
}); });
after(() => fs.rmSync(ROOT, { recursive: true, force: true })); after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
// Long enough ago that no reconcile treats the run as just started.
const start = (id, extra = {}) => const start = (id, extra = {}) =>
runs.recordRun({ id, mode: "interactive", cwd: "/r", status: "running", laneId: 4, ...extra }); runs.recordRun({
id,
mode: "interactive",
cwd: "/r",
status: "running",
laneId: 4,
startedAt: Date.parse("2026-01-01T00:00:00Z"),
...extra,
});
describe("dashboard-runs", () => { describe("dashboard-runs", () => {
it("keeps the previous run of a pane as history when the pane starts again", () => { it("keeps the previous run of a pane as history when the pane starts again", () => {
@@ -77,6 +86,14 @@ describe("dashboard-runs", () => {
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays"); assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
}); });
it("never ends a run started a moment ago: another process's tmux list may predate it", () => {
start("agenthub-lane-30", { laneId: 30, startedAt: Date.now() - 2000 });
start("agenthub-lane-31", { laneId: 31, startedAt: Date.now() - 60_000 });
runs.reconcileOrphans(() => false);
assert.equal(runs.getRun("agenthub-lane-30").status, "running");
assert.equal(runs.getRun("agenthub-lane-31").status, "abandoned");
});
it("flags as abandoned only the running rows whose pane is gone", () => { it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 }); start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 }); start("agenthub-lane-6", { laneId: 6 });
+28
View File
@@ -143,6 +143,34 @@ describe("collectChanges — work range", () => {
assert.equal((await gitFacts(LANE)).untracked, 2); assert.equal((await gitFacts(LANE)).untracked, 2);
}); });
// #72: a change staged and then undone in the working tree is `MM` in
// `git status` while the file on disk equals HEAD — JetBrains' Changes view
// (GitChangesCollector) drops it after a `git diff HEAD`, and so does this.
it("leaves out a staged change the working tree has reverted (#72)", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
g(LANE, "add", "a.txt");
write(LANE, "a.txt", "one\ntwo\nthree\n"); // back to HEAD's content
write(LANE, "keep.txt", "staged\n");
g(LANE, "add", "keep.txt");
write(LANE, "keep.txt", "staged, then edited\n"); // MM and still a change
assert.deepEqual(porcelain(LANE).sort(), ["MM a.txt", "MM keep.txt"]);
const out = await collectChanges(LANE, { range: "work" });
assert.deepEqual(
out.files.map((f) => f.path),
["keep.txt"]
);
});
it("still lists a file whose only change is its mode, as JetBrains does", async () => {
fs.chmodSync(path.join(LANE, "a.txt"), 0o755);
const out = await collectChanges(LANE, { range: "work" });
assert.deepEqual(
out.files.map((f) => [f.path, f.added, f.deleted]),
[["a.txt", 0, 0]]
);
});
it("returns hunks for every file in full mode", async () => { it("returns hunks for every file in full mode", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n"); write(LANE, "a.txt", "one\nTWO\nthree\n");
const out = await collectChanges(LANE, { range: "work", full: true }); const out = await collectChanges(LANE, { range: "work", full: true });
+19
View File
@@ -579,6 +579,25 @@ describe("GET /api/lanes/browse", () => {
assert.equal(notADir.status, 400); assert.equal(notADir.status, 400);
assert.equal(notADir.body.error.code, "ENOTADIR"); assert.equal(notADir.body.error.code, "ENOTADIR");
}); });
it("with files=1 lists files too, opening a file path at its folder", async () => {
const filePath = path.join(ROOT, "b-file.txt");
fs.writeFileSync(filePath, "hi\n");
const response = await request(
"GET",
`/api/lanes/browse?files=1&path=${encodeURIComponent(filePath)}`
);
assert.equal(response.status, 200);
assert.equal(response.body.path, ROOT);
const file = response.body.entries.find((e) => e.name === "b-file.txt");
assert.equal(file?.file, true);
assert.equal(file.path, filePath);
const dir = response.body.entries.find((e) => e.name === "src-repo");
assert.ok(dir && !dir.file);
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.ok(!dirsOnly.body.entries.some((e) => e.name === "b-file.txt"));
});
}); });
describe("destructive lane lifecycle actions", () => { describe("destructive lane lifecycle actions", () => {
+10
View File
@@ -427,6 +427,16 @@ describe("lanes changed by another server on the same DB", () => {
}); });
describe("lane start input", () => { describe("lane start input", () => {
it("refuses to start in a workspace folder that no longer exists (409 ENOCWD)", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-gone-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
fs.rmSync(cwd, { recursive: true, force: true });
// Without the check the pane started in the account's home instead.
const r = await request("POST", `/api/lanes/${id}/start`, {});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ENOCWD");
});
it("answers 400 for a model that is not a plain model id", async () => { it("answers 400 for a model that is not a plain model id", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id; const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
+7
View File
@@ -44,6 +44,13 @@ describe("native-dialog", () => {
assert.ok(!args.slice(0, -2).join(" ").includes("shell script")); assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
}); });
it("brings osascript forward before the macOS dialog, so it does not open behind the browser", () => {
const [[, args]] = nd.dialogCommands("dir", "/x", "darwin");
const script = args.filter((_, i) => args[i - 1] === "-e");
assert.ok(script.indexOf("activate") > -1);
assert.ok(script.indexOf("activate") < script.findIndex((s) => s.includes("choose folder")));
});
it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => { it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => {
for (const kind of ["dir", "file"]) { for (const kind of ["dir", "file"]) {
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32"); const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
+21 -1
View File
@@ -374,7 +374,27 @@ describe("pty-run", () => {
if (args[0] === "new-session") newSessionArgv = args; if (args[0] === "new-session") newSessionArgv = args;
return ""; return "";
}); });
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" }); const prevHome = process.env.CLAUDE_HOME;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "bypass-home-"));
process.env.CLAUDE_HOME = home;
try {
// An account that never accepted the bypass warning: no flag, so its
// first session does not open on that warning (the Chat UI can't answer).
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
assert.ok(!newSessionArgv.includes("--allow-dangerously-skip-permissions"));
// ...unless the run asks for bypass itself: then the warning is wanted.
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo", permissionMode: "bypassPermissions" });
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
fs.writeFileSync(
path.join(home, "settings.json"),
JSON.stringify({ skipDangerousModePermissionPrompt: true })
);
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
} finally {
if (prevHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prevHome;
fs.rmSync(home, { recursive: true, force: true });
}
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions")); assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
// ...as an OPTION only: the starting mode stays the requested one, and // ...as an OPTION only: the starting mode stays the requested one, and
// `--dangerously-skip-permissions` (which would enable it) is never passed. // `--dangerously-skip-permissions` (which would enable it) is never passed.
+5 -7
View File
@@ -335,17 +335,15 @@ describe("GET /api/run/drop/:name", () => {
}); });
it("serves no file that is not a dropped image or text file", async () => { it("serves no file that is not a dropped image or text file", async () => {
const link = path.join(
os.tmpdir(),
`agenthub-drops-${os.userInfo().uid}`,
"fedcba9876543210.png"
);
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", { const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", {
method: "POST", method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") }, raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
}); });
// Planted in the real drop dir (the one this drop just made), not a
// guessed path: `<data dir>/drops` replaced `/tmp/agenthub-drops-<uid>`.
const link = path.join(path.dirname(pdf.body.path), "fedcba9876543210.png");
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
for (const name of [ for (const name of [
path.basename(pdf.body.path), // a drop, but no image path.basename(pdf.body.path), // a drop, but no image
"0123456789abcdef.png", // the right shape, never dropped "0123456789abcdef.png", // the right shape, never dropped
@@ -31,7 +31,15 @@ const { healAllRunIds } = require("../routes/lanes");
after(() => fs.rmSync(ROOT, { recursive: true, force: true })); after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const start = (id, laneId, cwd) => const start = (id, laneId, cwd) =>
runs.recordRun({ id, mode: "interactive", cwd, status: "running", laneId, sessionId: `s-${id}` }); runs.recordRun({
id,
mode: "interactive",
cwd,
status: "running",
laneId,
sessionId: `s-${id}`,
startedAt: Date.parse("2026-01-01T00:00:00Z"),
});
describe("a server without S3 on an S3 machine's database", () => { describe("a server without S3 on an S3 machine's database", () => {
it("never ends a member's run, and still ends its own", () => { it("never ends a member's run, and still ends its own", () => {
+88
View File
@@ -0,0 +1,88 @@
/**
* @file self-restart.test.js
* @description After an update, a server our service unit runs is restarted
* by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
* left the replacement holding the port while `Restart=always` started the
* unit every 3 s, each start exiting "already running".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { scheduleRestart, owningUnit } = require("../lib/self-restart");
function deps(unit, restartAnswer = "restarted") {
const calls = { restart: [], kill: 0, spawn: 0 };
return {
calls,
unit,
svc: () => ({
restartUserService: (name) => {
calls.restart.push(name);
return restartAnswer;
},
}),
kill: () => calls.kill++,
spawnHelper: () => {
calls.spawn++;
return { unref() {} };
},
};
}
describe("self-restart", () => {
it("tells which unit owns this process, from its cgroup or launchd label", () => {
const linux = (text) => owningUnit({ platform: "linux", cgroup: () => text });
assert.equal(linux("0::/system.slice/agenthub.service\n"), "systemd");
assert.equal(
linux("0::/user.slice/user-1001.slice/user@1001.service/app.slice/agenthub.service\n"),
"systemd"
);
assert.equal(linux("0::/user.slice/user-1001.slice/session-3.scope\n"), null);
assert.equal(linux("0::/system.slice/agenthub.service.d/x\n"), null);
assert.equal(
owningUnit({
platform: "linux",
cgroup: () => {
throw new Error("no /proc");
},
}),
null
);
assert.equal(
owningUnit({ platform: "darwin", env: { XPC_SERVICE_NAME: "io.smartgift.agenthub" } }),
"launchd"
);
assert.equal(owningUnit({ platform: "darwin", env: {} }), null);
});
it("asks systemd to restart its unit and starts no replacement", () => {
const d = deps("systemd");
assert.equal(scheduleRestart("/r", d), "service");
assert.deepEqual(d.calls.restart, ["agenthub"]);
assert.equal(d.calls.spawn, 0);
assert.equal(d.calls.kill, 0, "systemd's restart job stops this process");
});
it("exits for Restart=always when systemd cannot be asked — still no replacement", () => {
const d = deps("systemd", "failed");
assert.equal(scheduleRestart("/r", d), "exit");
assert.equal(d.calls.kill, 1);
assert.equal(d.calls.spawn, 0);
});
it("exits under launchd, whose KeepAlive starts the new code", () => {
const d = deps("launchd");
assert.equal(scheduleRestart("/r", d), "exit");
assert.equal(d.calls.kill, 1);
assert.equal(d.calls.spawn, 0);
assert.deepEqual(d.calls.restart, []);
});
it("hands over to the detached helper when no unit owns the server", () => {
const d = deps(null);
assert.equal(scheduleRestart("/r", d), "helper");
assert.equal(d.calls.spawn, 1);
assert.equal(d.calls.kill, 1);
});
});
@@ -148,3 +148,23 @@ describe("server mode: a member's skills/commands come from their own workspaces
assert.ok(names(r).includes("max-skill")); assert.ok(names(r).includes("max-skill"));
}); });
}); });
describe("the System page's facts for a developer", () => {
// Every string that looks like an absolute path, anywhere in a body.
const paths = (o, out = []) => {
if (typeof o === "string" && o.startsWith("/")) out.push(o);
else if (o && typeof o === "object") for (const v of Object.values(o)) paths(v, out);
return out;
};
it("carries no host or other-user path (DB, hooks file, transcript cache keys)", async () => {
const mia = await login("mia", "member-pass-1");
const info = await call("/api/settings/info", mia);
assert.equal(info.status, 200);
assert.deepEqual(paths(info.body), []);
assert.equal(typeof info.body.db.size, "number", "the numbers stay");
const boss = await login("boss", "admin-pass-1");
const full = await call("/api/settings/info", boss);
assert.ok(full.body.db.path, "an admin still sees the host's paths");
});
});
+19
View File
@@ -173,3 +173,22 @@ describe("an analyst's workspace is theirs, like a developer's", () => {
} }
}); });
}); });
describe("a member's new project folder", () => {
it("is made as the member, and nothing else is made for anyone", () => {
const { fakeSudo } = require("./fixtures/fake-sudo");
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
const fresh = path.join(USERS, "mia", "brand-new", "sub");
assert.equal(runAs.makeMemberFolder(fresh), true);
assert.ok(fs.statSync(fresh).isDirectory());
const call = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
assert.ok(call && call.includes("ah-mia") && call.includes(fresh) && call.includes("mkdir"));
sudo.calls.length = 0;
assert.equal(runAs.makeMemberFolder(path.join(USERS, "mia")), false, "the home itself");
assert.equal(runAs.makeMemberFolder(path.join(TMP, "host-new")), false, "not a member's");
assert.equal(runAs.makeMemberFolder(path.join(USERS, "nobody", "x")), false, "no account");
assert.deepEqual(sudo.calls, []);
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
});
});
+16
View File
@@ -248,4 +248,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
"/opt/agenthub/libexec/write-as" "/opt/agenthub/libexec/write-as"
); );
}); });
it("counts a member's tmux it cannot stat as maybe there, a missing one as none", () => {
const home = path.join(TMP, "users", "zoe");
const acct = { name: "zoe", login: "ah-zoe", home, uid: 5002 };
assert.equal(runAs.mayHaveTmux(acct), false, "no .agenthub at all");
const dir = path.join(home, ".agenthub", "tmux");
fs.mkdirSync(dir, { recursive: true });
assert.equal(runAs.mayHaveTmux(acct), false, "no socket yet");
// The member's own 0700 dir, seen by a dashboard without CAP_DAC_READ_SEARCH.
fs.chmodSync(dir, 0o000);
try {
if (process.getuid() !== 0)
assert.equal(runAs.mayHaveTmux(acct), true, "EACCES proves nothing");
} finally {
fs.chmodSync(dir, 0o700);
}
});
}); });
+49 -1
View File
@@ -12,7 +12,8 @@
* non-admin owns no host-folder workspace and a pane's cwd is its * non-admin owns no host-folder workspace and a pane's cwd is its
* workspace's account's; a pane token works only from its pane's uid; an * workspace's account's; a pane token works only from its pane's uid; an
* analyst's GET path stays in their own folder; a drop goes to the pane's * analyst's GET path stays in their own folder; a drop goes to the pane's
* account. A fake passwd (every member = this test's uid) and server.conf * account; an image a session read is served only from a member session's
* own home (a host session's from anywhere). A fake passwd (every member = this test's uid) and server.conf
* make the members provisioned; fake sudo runs their commands as this * make the members provisioned; fake sudo runs their commands as this
* account — and a fake `sudo` on PATH that never returns stands in for a * account — and a fake `sudo` on PATH that never returns stands in for a
* stalled member, in a child process. * stalled member, in a child process.
@@ -674,7 +675,54 @@ describe("a dropped file", () => {
const host = await drop("boss", hostLane); const host = await drop("boss", hostLane);
assert.equal(host.status, 200, JSON.stringify(host.body)); assert.equal(host.status, 200, JSON.stringify(host.body));
assert.equal(path.dirname(host.body.path), path.join(DATA, "drops")); assert.equal(path.dirname(host.body.path), path.join(DATA, "drops"));
// …and the admin's chat shows it back, though the GET names no workspace.
fs.mkdirSync(path.join(USERS, "boss", ".agenthub", "handoff"), { recursive: true });
const shown = await fetch(`${BASE}/api/run/drop/${path.basename(host.body.path)}`, {
headers: { cookie: cookies.boss },
});
assert.equal(shown.status, 200);
assert.equal(await shown.text(), "hello");
// Another member's workspace is not one to name. // Another member's workspace is not one to name.
assert.equal((await drop("bob", miaLane)).status, 404); assert.equal((await drop("bob", miaLane)).status, 404);
}); });
}); });
describe("an image a session read (the Chat pane's Files read preview)", () => {
const readBy = (sid, cwd, file) => {
db.prepare("INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)").run(sid, cwd);
db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PostToolUse', 'Read', ?)"
).run(sid, JSON.stringify({ tool_input: { file_path: file } }));
};
const get = (sid, file) =>
fetch(`${BASE}/api/sessions/${sid}/image?path=${encodeURIComponent(file)}`, {
headers: { cookie: cookies.boss },
});
it("is served for the host's own session and a member's file in that member's home", async () => {
const hostPng = path.join(TMP, "hostdir", "shot.png");
const miaPng = path.join(MIA, "proj", "shot.png");
fs.mkdirSync(path.dirname(hostPng), { recursive: true });
fs.writeFileSync(hostPng, "HOSTPNG");
fs.writeFileSync(miaPng, "MIAPNG");
readBy("img-host", path.join(TMP, "hostdir"), hostPng);
readBy("img-mia", path.join(MIA, "proj"), miaPng);
const host = await get("img-host", hostPng);
assert.equal(host.status, 200);
assert.equal(await host.text(), "HOSTPNG");
const mia = await get("img-mia", miaPng);
assert.equal(mia.status, 200);
assert.equal(await mia.text(), "MIAPNG");
});
it("is refused when a member's session names a file outside that member's home", async () => {
readBy("img-mia-out", path.join(MIA, "proj"), path.join(TMP, "hostdir", "shot.png"));
readBy("img-mia-bob", path.join(MIA, "proj"), path.join(BOB, "b.png"));
fs.writeFileSync(path.join(BOB, "b.png"), "BOBPNG");
for (const f of [path.join(TMP, "hostdir", "shot.png"), path.join(BOB, "b.png")]) {
const r = await get(f.includes("bob") ? "img-mia-bob" : "img-mia-out", f);
assert.equal(r.status, 404, f);
assert.ok(!/PNG/.test(await r.text()));
}
});
});
+20 -1
View File
@@ -18,7 +18,7 @@ const fs = require("node:fs");
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`); const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB; process.env.DASHBOARD_DB_PATH = TEST_DB;
const { classifyTranscriptSender, patchCounts } = require("../routes/sessions"); const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions");
const { db } = require("../db"); const { db } = require("../db");
after(() => { after(() => {
@@ -168,3 +168,22 @@ describe("patchCounts — the +/- lines of a Write/Edit result", () => {
assert.equal(patchCounts(undefined), null); assert.equal(patchCounts(undefined), null);
}); });
}); });
describe("askAnswers — what the user picked or typed for an AskUserQuestion", () => {
it("returns each question's answer", () => {
const r = {
questions: [{ question: "Commit?", options: [{ label: "Yes" }, { label: "No" }] }],
answers: { "Commit?": "Yes", "Notes?": "typed text" },
annotations: {},
};
assert.deepEqual(askAnswers(r), { "Commit?": "Yes", "Notes?": "typed text" });
});
it("drops a non-string answer and answers null for a result that is no question", () => {
assert.deepEqual(askAnswers({ answers: { a: "x", b: 3 } }), { a: "x" });
assert.equal(askAnswers({ answers: {} }), null);
assert.equal(askAnswers({ answers: ["x"] }), null);
assert.equal(askAnswers({ stdout: "ok" }), null);
assert.equal(askAnswers("text"), null);
});
});
+16 -2
View File
@@ -258,8 +258,21 @@ function claimToolSessions(sessionId = null) {
} }
const runningStmt = db.prepare( const runningStmt = db.prepare(
"SELECT id, tool, session_id, cwd FROM dashboard_runs WHERE status IN ('running', 'spawning')" "SELECT id, tool, session_id, cwd, started_at FROM dashboard_runs WHERE status IN ('running', 'spawning')"
); );
/**
* How long a new run is never judged dead. A tmux list is a cached answer —
* a member's may be up to 15 s old (tmux.js MEMBER_LIST_MAX_AGE_MS) — and only
* the process that started a pane writes it into its own cache, so another
* dashboard on the same database (a dev server) ended a pane it had simply
* not listed yet: the proxy gateway then answered 401 to a live Codex.
*/
const YOUNG_RUN_MS = 20_000;
function isYoungRun(startedAt, now = Date.now()) {
const t = Date.parse(startedAt || "");
return Number.isFinite(t) && now - t < YOUNG_RUN_MS;
}
const endStmt = db.prepare(` const endStmt = db.prepare(`
UPDATE dashboard_runs UPDATE dashboard_runs
SET status = @status, SET status = @status,
@@ -300,7 +313,7 @@ function reconcileOrphans(isAlive = () => false) {
let changes = 0; let changes = 0;
const { unseenMemberFolder } = require("./linux-accounts"); const { unseenMemberFolder } = require("./linux-accounts");
for (const row of runningStmt.all()) { for (const row of runningStmt.all()) {
if (isAlive(row.id) || unseenMemberFolder(row.cwd)) continue; if (isAlive(row.id) || unseenMemberFolder(row.cwd) || isYoungRun(row.started_at)) continue;
const interrupted = const interrupted =
row.tool !== "shell" && row.tool !== "shell" &&
!!row.session_id && !!row.session_id &&
@@ -368,6 +381,7 @@ module.exports = {
getRun, getRun,
forgetShellRun, forgetShellRun,
reconcileOrphans, reconcileOrphans,
isYoungRun,
listInterrupted, listInterrupted,
dismissInterrupted, dismissInterrupted,
proxyForSession, proxyForSession,
+6 -2
View File
@@ -395,13 +395,17 @@ async function summarizeWork(dir) {
const [entries, numstat] = await Promise.all([ const [entries, numstat] = await Promise.all([
laneStatus(dir), laneStatus(dir),
// --relative: cwd-scoped, cwd-relative paths, so they key the status ones. // --relative: cwd-scoped, cwd-relative paths, so they key the status ones.
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })), git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => null),
]); ]);
const stats = parseNumstat(numstat.stdout); const stats = parseNumstat(numstat?.stdout ?? "");
const files = []; const files = [];
for (const { code, path: file } of entries) { for (const { code, path: file } of entries) {
const untracked = code === "??"; const untracked = code === "??";
const stat = stats.get(file); const stat = stats.get(file);
// `MM` with no line against HEAD: a staged change the working tree has
// reverted — nothing to commit from the file on disk. JetBrains' Changes
// view drops it the same way (#72). Only when the numstat answered.
if (code === "MM" && numstat && !stat) continue;
// One read per untracked file, not two: its line count and its // One read per untracked file, not two: its line count and its
// binary-ness are the same buffer answered twice. // binary-ness are the same buffer answered twice.
// Sequential on purpose: `normal` folds untracked dirs, so this is short. // Sequential on purpose: `normal` folds untracked dirs, so this is short.
+4
View File
@@ -85,6 +85,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
const title = TITLES[kind]; const title = TITLES[kind];
if (platform === "darwin") { if (platform === "darwin") {
const what = kind === "dir" ? "folder" : "file"; const what = kind === "dir" ? "folder" : "file";
// osascript started by a background server is not the frontmost app, so
// its dialog opened BEHIND the browser; `activate` brings it forward.
return [ return [
[ [
"osascript", "osascript",
@@ -92,6 +94,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
"-e", "-e",
"on run argv", "on run argv",
"-e", "-e",
"activate",
"-e",
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`, `POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
"-e", "-e",
"end run", "end run",
+29 -1
View File
@@ -165,6 +165,24 @@ function promptFileArgv(id, argv) {
return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)]; return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
} }
/**
* Whether the account a pane runs as has accepted Claude Code's bypass
* warning — Claude writes `skipDangerousModePermissionPrompt: true` to its
* user settings once it is. A member's (S3) is read from their home, owner-
* checked; the host's from CLAUDE_HOME. Unreadable reads as "not yet".
*/
function bypassAccepted(member) {
const file = member
? path.join(member.home, ".claude", "settings.json")
: path.join(require("./claude-home").getClaudeHome(), "settings.json");
try {
const text = require("./run-as").readFileSafe(file, "utf8", { max: 1 << 20 });
return JSON.parse(text).skipDangerousModePermissionPrompt === true;
} catch {
return false;
}
}
function buildClaudeArgv({ function buildClaudeArgv({
model, model,
permissionMode, permissionMode,
@@ -177,6 +195,7 @@ function buildClaudeArgv({
addDirs = [], addDirs = [],
systemPromptFile = null, systemPromptFile = null,
proxyArgv = [], proxyArgv = [],
offerBypass = true,
platform = process.platform, platform = process.platform,
}) { }) {
const argv = ["claude"]; const argv = ["claude"];
@@ -210,7 +229,11 @@ function buildClaudeArgv({
// plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could // plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could
// never reach bypass from inside the pane, while a real terminal could. // never reach bypass from inside the pane, while a real terminal could.
// The starting mode is still whatever --permission-mode says above. // The starting mode is still whatever --permission-mode says above.
argv.push("--allow-dangerously-skip-permissions"); // Only once the account has accepted Claude Code's bypass warning
// (`offerBypass`, see bypassAccepted): before that the flag alone puts the
// warning in front of a brand-new account's first session — one the Chat UI
// cannot answer — although nobody asked for bypass.
if (offerBypass) argv.push("--allow-dangerously-skip-permissions");
if (model) argv.push("--model", model); if (model) argv.push("--model", model);
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort); if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
if (resumeSessionId) argv.push("--resume", resumeSessionId); if (resumeSessionId) argv.push("--resume", resumeSessionId);
@@ -512,6 +535,11 @@ function spawnRun(args) {
resumeSessionId, resumeSessionId,
forkSession: forkSession === true, forkSession: forkSession === true,
newSessionId, newSessionId,
// Asking for bypass is consent to see its warning; otherwise only an
// account that already accepted it gets bypass in its shift+tab cycle.
offerBypass:
permissionMode === "bypassPermissions" ||
(entry.launcher === "claude" && bypassAccepted(require("./run-as").accountForLane(laneId))),
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null, worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
resumeLast, resumeLast,
bypass: bypass === true, bypass: bypass === true,
+45 -12
View File
@@ -133,13 +133,22 @@ function memberHomes() {
function membersWithTmux() { function membersWithTmux() {
return memberHomes() return memberHomes()
.map((home) => accountForPath(home)) .map((home) => accountForPath(home))
.filter((acct) => { .filter(mayHaveTmux);
try { }
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch { /**
return false; * Whether the member may have a tmux server. Only a missing socket says no:
} * a member's `.agenthub/tmux` is theirs (0700), and a dashboard without
}); * CAP_DAC_READ_SEARCH (a dev server on the same database) gets EACCES there
* — that proves nothing, and reading it as "no server" ended every run of
* theirs within seconds. Their list is asked through sudo, which can tell.
*/
function mayHaveTmux(acct) {
try {
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch (err) {
return err.code !== "ENOENT" && err.code !== "ENOTDIR";
}
} }
// uid by login, from /etc/passwd (world-readable; node has no getpwnam). // uid by login, from /etc/passwd (world-readable; node has no getpwnam).
@@ -357,8 +366,7 @@ function openSafe(file) {
// at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and // at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and
// never past the size `fstat` gave — a file growing under the read is cut. // never past the size `fstat` gave — a file growing under the read is cut.
const MEMBER_READ_MAX = 64 * 1024 * 1024; const MEMBER_READ_MAX = 64 * 1024 * 1024;
const tooBig = (file) => const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */ /** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) { function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
@@ -369,7 +377,7 @@ function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file); if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size); const buf = Buffer.alloc(size);
let off = 0; let off = 0;
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0; ) off += n; for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0;) off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off); return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally { } finally {
fs.closeSync(fd); fs.closeSync(fd);
@@ -398,7 +406,7 @@ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file); if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size); const buf = Buffer.alloc(size);
let off = 0; let off = 0;
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0; ) for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0;)
off += n; off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off); return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally { } finally {
@@ -438,7 +446,10 @@ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
let start = 0; let start = 0;
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) { for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
if (skipping || partLen + (i - start) > maxLine) yield ""; if (skipping || partLen + (i - start) > maxLine) yield "";
else yield text(partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i)); else
yield text(
partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i)
);
parts = []; parts = [];
partLen = 0; partLen = 0;
skipping = false; skipping = false;
@@ -477,6 +488,26 @@ function sameOwner(...paths) {
/** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */ /** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */
const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir"); const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir");
/**
* S3: make a missing workspace folder inside a member's own home, AS them
* (`mkdir -p`, so only where they could themselves). A new member has no
* other way to their first project: their home is no workspace (EFOLDER).
* False — nothing made — for any other folder, the home itself, or a member
* with no usable account.
*/
function makeMemberFolder(dir) {
let acct;
try {
acct = accountForPath(dir);
} catch {
return false;
}
const abs = path.resolve(dir);
if (!acct || abs === acct.home) return false;
mkdirAs(acct, abs);
return true;
}
const removeAs = (acct, p) => writeAs(acct, p, "remove"); const removeAs = (acct, p) => writeAs(acct, p, "remove");
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */ /** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
@@ -502,6 +533,7 @@ module.exports = {
assertOwnerFits, assertOwnerFits,
hasLinuxAccount, hasLinuxAccount,
membersWithTmux, membersWithTmux,
mayHaveTmux,
memberHomes, memberHomes,
ownerForRead, ownerForRead,
tmuxSocket, tmuxSocket,
@@ -516,6 +548,7 @@ module.exports = {
handoffWrite, handoffWrite,
writeAs, writeAs,
mkdirAs, mkdirAs,
makeMemberFolder,
removeAs, removeAs,
launchArgv, launchArgv,
sameOwner, sameOwner,
+63 -4
View File
@@ -7,26 +7,85 @@
* it, and hands the "wait for the old PID to actually die, then spawn the * it, and hands the "wait for the old PID to actually die, then spawn the
* replacement" job to a detached helper script — the dying process can't do * replacement" job to a detached helper script — the dying process can't do
* that itself without racing its own replacement for the port. * that itself without racing its own replacement for the port.
*
* A server our service unit runs (systemd `agenthub.service`, system or user;
* the launchd job) is restarted BY that manager instead: a detached
* replacement would hold the port while `Restart=always` starts the unit
* again every few seconds, each start exiting "already running" — forever.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const UNIT = "agenthub";
/**
* "systemd" when this process lives in our unit's cgroup, "launchd" when it
* is our LaunchAgent job, else null. Pure reads: no process is spawned.
*/
function owningUnit({
platform = process.platform,
cgroup = () => fs.readFileSync("/proc/self/cgroup", "utf8"),
env = process.env,
} = {}) {
if (platform === "darwin") {
return env.XPC_SERVICE_NAME === `io.smartgift.${UNIT}` ? "launchd" : null;
}
try {
return cgroup()
.split("\n")
.some((l) => l.trim().endsWith(`/${UNIT}.service`))
? "systemd"
: null;
} catch {
return null;
}
}
/** /**
* @param {string} [root] repo root the helper should run from. * @param {string} [root] repo root the helper should run from.
* @returns {"service"|"exit"|"helper"} how the restart was handed over.
*/ */
function scheduleRestart(root = path.join(__dirname, "..", "..")) { function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
const {
unit = owningUnit(),
svc = () => require("../../scripts/service-manager"),
kill = () => process.kill(process.pid, "SIGTERM"),
spawnHelper = spawn,
} = deps;
// systemd: one non-blocking restart job (sudo -n for the system unit). If
// that cannot be asked, a plain exit still comes back — the unit is
// Restart=always — but never with a second server beside it.
if (unit === "systemd") {
let restarted = false;
try {
restarted = svc().restartUserService(UNIT) === "restarted";
} catch {
/* fall through to the exit */
}
if (restarted) return "service";
kill();
return "exit";
}
// launchd: KeepAlive restarts the job on exit; a `bootout` from inside the
// job would wait for this very process.
if (unit === "launchd") {
kill();
return "exit";
}
const helperPath = path.join(root, "scripts", "restart-helper.js"); const helperPath = path.join(root, "scripts", "restart-helper.js");
const entry = process.argv[1] || path.join(root, "server", "index.js"); const entry = process.argv[1] || path.join(root, "server", "index.js");
const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], { const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry], {
cwd: root, cwd: root,
detached: true, detached: true,
stdio: "ignore", stdio: "ignore",
env: process.env, env: process.env,
}); });
helper.unref(); helper.unref();
process.kill(process.pid, "SIGTERM"); kill();
return "helper";
} }
module.exports = { scheduleRestart }; module.exports = { scheduleRestart, owningUnit };
+7 -2
View File
@@ -226,16 +226,21 @@ function listOutput(acct = null) {
} }
// A member's list is never waited for: a read answers from `listed` and, // A member's list is never waited for: a read answers from `listed` and,
// once that is LIST_TTL_MS old, starts one async re-list. Until a first // once that is MEMBER_LIST_TTL_MS old, starts one async re-list. Until a first
// answer — or once the last is MEMBER_LIST_MAX_AGE_MS old (nobody polled) — // answer — or once the last is MEMBER_LIST_MAX_AGE_MS old (nobody polled) —
// or while their server fails, it throws: their panes are UNKNOWN. A session // or while their server fails, it throws: their panes are UNKNOWN. A session
// this process creates or kills is written into the answer at once (`noteSession`). // this process creates or kills is written into the answer at once (`noteSession`).
const MEMBER_LIST_MAX_AGE_MS = 15_000; // > the 10 s heal beat const MEMBER_LIST_MAX_AGE_MS = 15_000; // > the 10 s heal beat
// A member's list is a `sudo` (fork + PAM session + 3 auth.log lines) — at a
// 1 s TTL every open dashboard re-asked each member ~30 times a minute. A pane
// this process starts or ends is corrected at once (`noteSession`); only one
// that exits on its own reads live for up to these 5 s.
const MEMBER_LIST_TTL_MS = 5_000;
const refreshing = new Set(); // member logins with a list in flight const refreshing = new Set(); // member logins with a list in flight
function memberListOutput(acct) { function memberListOutput(acct) {
const hit = listed.get(acct.login); const hit = listed.get(acct.login);
const age = hit ? Date.now() - hit.at : Infinity; const age = hit ? Date.now() - hit.at : Infinity;
if (age >= LIST_TTL_MS) refreshList(acct); if (age >= MEMBER_LIST_TTL_MS) refreshList(acct);
if (age >= MEMBER_LIST_MAX_AGE_MS) { if (age >= MEMBER_LIST_MAX_AGE_MS) {
throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" }); throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" });
} }
+33 -4
View File
@@ -35,7 +35,7 @@ const { sameOriginGuard } = require("./run");
const { isLocalRequest } = require("../lib/same-origin"); const { isLocalRequest } = require("../lib/same-origin");
const scope = require("../lib/scope"); const scope = require("../lib/scope");
const { writeLaneFile } = require("../lib/lane-file-write"); const { writeLaneFile } = require("../lib/lane-file-write");
const { pickPath } = require("../lib/native-dialog"); const { pickPath, startDir } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight"); const { preflight } = require("../lib/lane-preflight");
const { const {
LANES_ROOT, LANES_ROOT,
@@ -128,6 +128,10 @@ function healRunId(lane, live = null) {
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane; if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
if (live) { if (live) {
if (live.has(lane.run_id)) return lane; if (live.has(lane.run_id)) return lane;
// Started a moment ago: a cached list may not show it yet (dashboard-runs.js).
const dashboardRuns = require("../lib/dashboard-runs");
const row = dashboardRuns.getRun(lane.run_id);
if (row && row.status === "running" && dashboardRuns.isYoungRun(row.started_at)) return lane;
} else { } else {
const run = runs.getRun(lane.run_id); const run = runs.getRun(lane.run_id);
if (run && run.status === "running") return lane; if (run && run.status === "running") return lane;
@@ -560,15 +564,20 @@ router.post("/gc", sameOriginGuard, (req, res) => {
* expose absolute filesystem paths from a native picker, so path selection is * expose absolute filesystem paths from a native picker, so path selection is
* done by browsing server-side instead. Read-only; registered ahead of * done by browsing server-side instead. Read-only; registered ahead of
* "/:id" so the literal "browse" segment is never captured as a lane id. * "/:id" so the literal "browse" segment is never captured as a lane id.
* `files=1` (a file field's fallback) lists files too, and opens `path` the
* way the native picker would (`startDir`: `~`, a file's folder, else home).
*/ */
router.get("/browse", (req, res) => { router.get("/browse", (req, res) => {
// A developer browses only their own folder (by real path: a link in it // A developer browses only their own folder (by real path: a link in it
// cannot point the listing elsewhere) — the dashboard lists with its own // cannot point the listing elsewhere) — the dashboard lists with its own
// rights, CAP_DAC_READ_SEARCH under S3, not theirs. // rights, CAP_DAC_READ_SEARCH under S3, not theirs.
const own = scope.ownFolder(req); const own = scope.ownFolder(req);
const files = req.query.files === "1";
const raw = const raw =
typeof req.query.path === "string" && req.query.path.trim() typeof req.query.path === "string" && req.query.path.trim()
? req.query.path ? files
? startDir(req.query.path)
: req.query.path
: own || os.homedir(); : own || os.homedir();
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw); const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res); if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
@@ -589,12 +598,14 @@ router.get("/browse", (req, res) => {
try { try {
entries = fs entries = fs
.readdirSync(resolved, { withFileTypes: true }) .readdirSync(resolved, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".")) .filter((e) => (e.isDirectory() || (files && e.isFile())) && !e.name.startsWith("."))
.map((e) => { .map((e) => {
const full = path.join(resolved, e.name); const full = path.join(resolved, e.name);
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true };
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) }; return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
}) })
.sort((a, b) => a.name.localeCompare(b.name)); // Folders first, as every file picker lists them.
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
} catch { } catch {
// An unreadable entry mid-listing is skipped, not a request failure. // An unreadable entry mid-listing is skipped, not a request failure.
} }
@@ -2060,6 +2071,24 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try { try {
switch (action) { switch (action) {
case "start": { case "start": {
// A folder that is gone (or never was) would start the pane in the
// account's home instead, under this workspace's name. EACCES is not
// "gone": a member's folder may be unreadable to this process.
try {
if (!fs.statSync(lane.cwd).isDirectory())
throw Object.assign(new Error(), { code: "ENOTDIR" });
} catch (err) {
// A member's new project folder is made for them, as them.
const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd);
if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
return res.status(409).json({
error: {
code: "ENOCWD",
message: `the workspace folder does not exist: ${lane.cwd}`,
},
});
}
}
// `"auto"`: the first free tab, picked under the lane lock below — a // `"auto"`: the first free tab, picked under the lane lock below — a
// client's own pick comes from a list that may already be stale. // client's own pick comes from a list that may already be stale.
const auto = body.slot === "auto"; const auto = body.slot === "auto";
+17 -10
View File
@@ -531,11 +531,19 @@ router.get("/drop/:name", (req, res) => {
// for a link after the check): a regular file this account wrote. NOFOLLOW // for a link after the check): a regular file this account wrote. NOFOLLOW
// refuses a link outright; NONBLOCK keeps a FIFO from hanging the loop. // refuses a link outright; NONBLOCK keeps a FIFO from hanging the loop.
// S3: the requester's own drop dir, owned by their member account — so an // S3: the requester's own drop dir, owned by their member account — so an
// admin viewing a member's session gets the name chip, not the image. // admin viewing a member's session gets the name chip, not the image. An
// admin's host-folder workspace drops into the HOST's dir (only an admin
// may own one), and the chat's GET names no workspace: that dir too.
const dirs = type ? [dropDir(req)] : [];
if (dirs[0]?.member && req.user?.role === "admin" && !req.query?.laneId) {
dirs.push({
dir: path.join(require("../lib/claude-home").getDataDir(), "drops"),
member: null,
});
}
let fd = null; let fd = null;
try { for (const { dir, member } of dirs) {
if (type) { try {
const { dir, member } = dropDir(req);
fd = fs.openSync( fd = fs.openSync(
path.join(dir, name), path.join(dir, name),
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0) fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0)
@@ -546,13 +554,12 @@ router.get("/drop/:name", (req, res) => {
: typeof process.getuid === "function" : typeof process.getuid === "function"
? process.getuid() ? process.getuid()
: st.uid; : st.uid;
if (!st.isFile() || st.uid !== uid) { if (st.isFile() && st.uid === uid) break;
fs.closeSync(fd); fs.closeSync(fd);
fd = null; fd = null;
} } catch {
/* pruned, never dropped, or a link */
} }
} catch {
/* pruned, never dropped, or a link */
} }
if (fd === null) { if (fd === null) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such image" } }); return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such image" } });
+29 -4
View File
@@ -480,8 +480,10 @@ router.get("/:id/outline", (req, res) => {
// pane's Files read preview when it lies outside the workspace (a screenshot // pane's Files read preview when it lies outside the workspace (a screenshot
// under ~/.cache, /tmp). The path is authorized only by that event, so: raster // under ~/.cache, /tmp). The path is authorized only by that event, so: raster
// types only, under a sandbox CSP with nosniff; never for a pushed session // types only, under a sandbox CSP with nosniff; never for a pushed session
// (another machine's paths), and not in server mode, where a member's hook // (another machine's paths). In server mode only with S3, where a member's hook
// could name a host-only file that this process would then read as the host. // reports sessions in its own home only (hook-auth.js): a member's session gets
// only a file inside that home and owned by them; a host session (a trusted
// peer's) anything. Without S3 a member's hook could name a host-only file.
const READ_IMAGE_TYPES = { const READ_IMAGE_TYPES = {
png: "image/png", png: "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
@@ -500,12 +502,18 @@ router.get("/:id/image", async (req, res) => {
const fail = (status, code, message) => res.status(status).json({ error: { code, message } }); const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
const session = stmts.getSession.get(req.params.id); const session = stmts.getSession.get(req.params.id);
if (!session) return fail(404, "NOT_FOUND", "Session not found"); if (!session) return fail(404, "NOT_FOUND", "Session not found");
if (isServerMode() || String(session.source || "").startsWith("push:")) { const s3 = require("../lib/linux-accounts").s3Enabled();
if ((isServerMode() && !s3) || String(session.source || "").startsWith("push:")) {
return fail(403, "EUNSUPPORTED", "not available for this session"); return fail(403, "EUNSUPPORTED", "not available for this session");
} }
const file = typeof req.query.path === "string" ? req.query.path : ""; const file = typeof req.query.path === "string" ? req.query.path : "";
const type = READ_IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()]; const type = READ_IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
if (!path.isAbsolute(file) || !type) return fail(400, "ENOTIMAGE", "not an image path"); if (!path.isAbsolute(file) || !type) return fail(400, "ENOTIMAGE", "not an image path");
const runAs = require("../lib/run-as");
const member = s3 ? runAs.ownerForRead(session.cwd) : null;
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
return fail(404, "ENOTFOUND", "the session never read that image");
}
if (!sessionReadFile.get(session.id, file)) { if (!sessionReadFile.get(session.id, file)) {
return fail(404, "ENOTFOUND", "the session never read that image"); return fail(404, "ENOTFOUND", "the session never read that image");
} }
@@ -514,7 +522,11 @@ router.get("/:id/image", async (req, res) => {
// NONBLOCK: a FIFO named like an image must not hang the event loop. // NONBLOCK: a FIFO named like an image must not hang the event loop.
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)); fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
const st = await fh.stat(); const st = await fh.stat();
if (!st.isFile()) return fail(404, "ENOTFOUND", "no such image"); // A file in a member's home is read only when that member owns it (openSafe's rule).
const fileOwner = s3 ? runAs.ownerForRead(file) : null;
if (!st.isFile() || (fileOwner && st.uid !== fileOwner.uid)) {
return fail(404, "ENOTFOUND", "no such image");
}
if (st.size > READ_IMAGE_MAX) return fail(400, "ETOOBIG", "image is larger than 20 MB"); if (st.size > READ_IMAGE_MAX) return fail(400, "ETOOBIG", "image is larger than 20 MB");
const buf = await fh.readFile(); const buf = await fh.readFile();
res.set({ res.set({
@@ -1017,6 +1029,7 @@ router.get("/:id/transcript", async (req, res) => {
} else if (block.type === "tool_result") { } else if (block.type === "tool_result") {
// A line carries one tool result; an edit's +/- counts ride on it. // A line carries one tool result; an edit's +/- counts ride on it.
const diff = patchCounts(entry.toolUseResult); const diff = patchCounts(entry.toolUseResult);
const answers = askAnswers(entry.toolUseResult);
content.push({ content.push({
type: "tool_result", type: "tool_result",
id: block.tool_use_id || null, id: block.tool_use_id || null,
@@ -1028,6 +1041,7 @@ router.get("/:id/transcript", async (req, res) => {
), ),
is_error: !!block.is_error, is_error: !!block.is_error,
...(diff ? { diff } : {}), ...(diff ? { diff } : {}),
...(answers ? { answers } : {}),
}); });
} }
} }
@@ -1318,6 +1332,16 @@ function patchCounts(r) {
return null; return null;
} }
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
* text → the option picked, or what the user typed. Null for any other result. */
function askAnswers(r) {
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
if (Array.isArray(r.answers)) return null;
const out = {};
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
return Object.keys(out).length ? out : null;
}
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by // A conversation turn — a prompt, an answer — is shown whole: it is bounded by
// the model's output and what the user typed. Only tool payloads and thinking // the model's output and what the user typed. Only tool payloads and thinking
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards // (the parts that can run to megabytes) keep the 10 KB cap. This one guards
@@ -1341,3 +1365,4 @@ module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical. // Exported for unit tests — sender attribution is correctness-critical.
module.exports.classifyTranscriptSender = classifyTranscriptSender; module.exports.classifyTranscriptSender = classifyTranscriptSender;
module.exports.patchCounts = patchCounts; module.exports.patchCounts = patchCounts;
module.exports.askAnswers = askAnswers;
+9 -4
View File
@@ -35,7 +35,7 @@ const {
importExportBundle, importExportBundle,
exportableRun, exportableRun,
} = require("../lib/data-transfer"); } = require("../lib/data-transfer");
const { sessionWhere, bySessionWhere } = require("../lib/scope"); const { sessionWhere, bySessionWhere, memberId } = require("../lib/scope");
function getDbSize() { function getDbSize() {
try { try {
@@ -127,15 +127,20 @@ router.get("/info", (req, res) => {
h1: getCount(60 * 60 * 1000), h1: getCount(60 * 60 * 1000),
}; };
// A developer (server mode) gets the numbers, never a path of the host's:
// the transcript cache is keyed by EVERY user's transcript path (their
// folders, their session ids), and the DB/settings paths are the host's.
const developer = memberId(req) != null;
const cache = transcriptCache.stats();
res.json({ res.json({
db: { db: {
path: DB_PATH, path: developer ? null : DB_PATH,
size: dbSize, size: dbSize,
counts, counts,
pragmas, pragmas,
load_stats, load_stats,
}, },
hooks: hookStatus, hooks: developer ? { installed: hookStatus.installed } : hookStatus,
server: { server: {
version: APP_VERSION, version: APP_VERSION,
uptime: process.uptime(), uptime: process.uptime(),
@@ -152,7 +157,7 @@ router.get("/info", (req, res) => {
free_mem: os.freemem(), free_mem: os.freemem(),
cpus: os.cpus().length, cpus: os.cpus().length,
}, },
transcript_cache: transcriptCache.stats(), transcript_cache: developer ? { ...cache, keys: undefined } : cache,
}); });
}); });
+3 -1
View File
@@ -24,9 +24,11 @@ const { schedulePluginUpdate } = require("../lib/plugin-update");
const router = Router(); const router = Router();
router.get("/status", async (_req, res) => { router.get("/status", async (req, res) => {
try { try {
const status = await getUpdatesStatus(); const status = await getUpdatesStatus();
// The install folder is the host's; a developer sees versions only.
if (require("../lib/scope").memberId(req) != null) status.repo_root = null;
res.json(status); res.json(status);
} catch (err) { } catch (err) {
res.status(500).json({ res.status(500).json({