Compare commits

..

10 Commits

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

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
2026-10-12 13:06:04 +07:00
nntrivi2001 2fd0c903a2 fix: v2.7.1 — the UI builds again (v2.7.0's update failed at npm run build)
- v2.7.0 could not be installed or updated to: the client build
  (`tsc -b && vite build`) type-checks test files too, and a new
  RunSetup test rendered PromptEditor without its required `slash` and
  `fileCwd` props, so `tsc -b` failed and every update stopped at
  "npm run build failed". The test now passes them.
- Verified on a clean v2.7.0 worktree + this fix: `tsc -b` and
  `vite build` both exit 0; RunSetup tests 19/19.
2026-10-10 15:18:00 +07:00
96 changed files with 3327 additions and 523 deletions
+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.
- **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 -3
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.7.0 (2026-10-10) — tabs from every workspace (opt-in), interrupted sessions offered for resume, chat drafts kept per session, chat right panel in every split pane._ 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.4 (2026-10-12) — Changes hides a staged change undone on disk (#72); chat says when the session has not started (#73); a member's missing folder is made as them, bypass offered only once accepted._ 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
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -9,7 +9,7 @@ _Last synced: v2.7.0 (2026-10-10) — tabs from every workspace (opt-in), interr
## 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)).
- `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.
- `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`.
@@ -57,7 +57,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-<id>` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane-<id>-<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale.
- **The whole-file editor is the one non-git write on the lane file surface, and it lives outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at. Do not let it create files or touch a binary.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
+8 -16
View File
@@ -6,9 +6,9 @@
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* with no desktop or no zenity/kdialog) it falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure) — for a file field
* too, which then lists files and picks one on click.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,12 +16,8 @@ import { useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
@@ -47,15 +43,10 @@ export function PathPickButton({
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} catch {
// The in-app browser is the fallback whatever went wrong with the
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
setFallback(true);
} finally {
setBusy(false);
}
@@ -80,6 +71,7 @@ export function PathPickButton({
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
kind={kind}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
+3 -1
View File
@@ -77,6 +77,7 @@ import {
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
@@ -215,6 +216,7 @@ function SagiBody() {
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const href = useSettingsHref();
const brain = useSagiBrain();
const place = useSagiPosition(size);
@@ -348,7 +350,7 @@ function SagiBody() {
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/appearance#pet");
navigate(href("appearance#pet"));
}}
/>
</SagiFlyout>
+11 -4
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -49,7 +49,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { NavLink } from "react-router-dom";
import { NavLink, useLocation } from "react-router-dom";
import { settingsHref } from "../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { useDialog } from "../hooks/useDialog";
import {
@@ -186,6 +187,12 @@ export function Sidebar({
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
// link's own match (the path) cannot tell when it is the one on screen.
const location = useLocation();
const settingsOn =
location.pathname.startsWith("/settings") ||
new URLSearchParams(location.search).has("settings");
// Checking for (and applying) an update acts on the host: an admin's.
const canUpdate = usePermissions().isAdmin;
const asideRef = useRef<HTMLElement>(null);
@@ -533,9 +540,9 @@ export function Sidebar({
}`}
>
<NavLink
to="/settings"
to={settingsHref("", location)}
title={collapsed ? t("nav:settings") : undefined}
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
+5 -84
View File
@@ -1,7 +1,7 @@
/**
* @file SplashScreen.tsx
* @description Branding splash shown once per browser session on app load. A
* dark-tech "constellation" overlay built around the node-graph brand mark:
* dark-tech "constellation" overlay built around the SmartGift brand mark:
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
* in a staggered cascade. The tagline and the subtext pair are picked at
* random (per mount) from localized pools in `splash.json`, so the copy is
@@ -143,7 +143,7 @@ export function SplashScreen() {
<div className="splash-grain" aria-hidden="true" />
<div className="splash-content">
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
{/* Brand mark - the SmartGift logo */}
<div className="splash-mark" aria-hidden="true">
<span className="splash-mark-glow" />
<BrandMark />
@@ -167,74 +167,10 @@ export function SplashScreen() {
);
}
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
* with animated connector lines and pulsing outer nodes. */
/** The SmartGift mark — the same file as the favicon, local so the splash
* needs no network. */
function BrandMark() {
return (
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
<defs>
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#818cf8" />
</linearGradient>
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
</linearGradient>
</defs>
<polygon
className="splash-hex"
points="16,2 28,9 28,23 16,30 4,23 4,9"
fill="url(#splashBg)"
/>
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
<line
className="splash-line"
x1="16"
y1="13"
x2="16"
y2="7"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-2"
x1="18.6"
y1="17.5"
x2="24"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-3"
x1="13.4"
y1="17.5"
x2="8"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
<circle
className="splash-node splash-node-2"
cx="24.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
<circle
className="splash-node splash-node-3"
cx="7.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
</svg>
);
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
}
/** Faint background constellation: a handful of nodes joined by thin lines that
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
animation: splashGlowPulse 2.6s ease-in-out infinite;
}
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
.splash-line-2 { animation-delay: 0.62s; }
.splash-line-3 { animation-delay: 0.74s; }
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
.splash-greeting {
display: flex;
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
.splash-star,
.splash-mark,
.splash-mark-glow,
.splash-hex,
.splash-line,
.splash-node,
.splash-dot,
.splash-greeting,
.splash-tagline,
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
opacity: 1 !important;
stroke-dashoffset: 0 !important;
transform: none !important;
}
}
@@ -1,14 +1,14 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* path fills the field, a Cancel changes nothing, and a folder or a file falls
* back to the in-app browser on ANY native-picker failure (a LAN browser's
* EREMOTE included).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/home/u/.ssh",
parent: "/home/u",
entries: [
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
],
});
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
await userEvent.click(screen.getByRole("button"));
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true);
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(toast.info).not.toHaveBeenCalled();
});
});
@@ -9,7 +9,11 @@
* the reader is there: anything that makes it taller — a new line, the live
* step, an image loading, the pane shown again after being hidden by CSS
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
* chat passes in whether the CLI waits on the user (`waiting`, with
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -62,7 +66,7 @@ import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import { MessageList, promptTokens } from "./MessageList";
import { useSpin } from "../../hooks/useSpin";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -89,6 +93,15 @@ interface ConversationViewProps {
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
/** The CLI is asking the user something (a permission prompt). */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** 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({
@@ -98,9 +111,26 @@ export function ConversationView({
onOpenFile,
live = false,
follow = 0,
waiting = false,
onOpenTerminal,
onContext,
onNotStarted,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -178,6 +208,7 @@ export function ConversationView({
limit: 50,
});
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setTotal(result.total);
setHasMore(result.has_more);
@@ -189,6 +220,7 @@ export function ConversationView({
// to show yet, and the poll below picks it up once it exists.
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
setNotStarted(notYet);
setMessages([]);
setTotal(0);
} finally {
@@ -228,6 +260,7 @@ export function ConversationView({
limit: 50,
});
if (gen !== genRef.current) return; // another transcript is shown now
setNotStarted(false);
if (result.messages.length === 0) return;
lastLineRef.current = result.last_line;
@@ -494,7 +527,9 @@ export function ConversationView({
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
<div ref={contentRef}>
{/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
@@ -516,6 +551,18 @@ export function ConversationView({
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.notStartedBody")}
</p>
{onOpenTerminal && (
<button type="button" onClick={onOpenTerminal} className="btn-secondary mt-3 text-xs">
{t("conversation.openTerminal")}
</button>
)}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
@@ -524,7 +571,14 @@ export function ConversationView({
</p>
</div>
) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
<MessageList
messages={messages}
loading={false}
onOpenFile={onOpenFile}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
/>
)}
</div>
</div>
@@ -6,7 +6,10 @@
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
* through `OpenFileContext` when the page provides one; `linkify` does the
* same links for plain text.
* same links for plain text. To make a reply easy to scan, bold is coloured
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
* of how serious it is.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
@@ -256,6 +259,22 @@ const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
// A link starts a word: "and/or" or "a/b/c" stays text.
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
// "2fd0c903" stay text.
const NUM_RE =
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
const numStart = (text: string, i: number) =>
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
const LABEL_RE =
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
const LABEL_CLASS = (word: string) =>
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
? "text-status-danger"
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
? "text-status-warning"
: "text-accent";
function UrlLink({ url }: { url: string }) {
return (
@@ -354,7 +373,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) {
push(
<strong className="font-semibold text-fg-primary">
<strong className="font-bold text-accent">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong>
);
@@ -419,6 +438,24 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
continue;
}
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
if (labelM) {
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
i += labelM[0].length;
continue;
}
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
if (numM) {
push(
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
{numM[0]}
</span>
);
i += numM[0].length;
continue;
}
buf += text[i]!;
i++;
}
@@ -6,10 +6,12 @@
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page. An image the prompt box
* attached reaches the transcript as its saved path; a user turn shows the
* image instead (`GET /api/run/drop/:name`), 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
* a System row; URLs and absolute paths in a turn are links (a path opens
* through `onOpenFile`). Each assistant turn that changed
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
* like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`), and a row (or the card's header, for
@@ -17,7 +19,12 @@
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
* chat (`live`) ends, until its turn is over, with what the model is doing
* now — the call still running, else "Thinking…" — and for how long.
* now — the calls still running (several when it runs them in parallel),
* else "Thinking…", or the question the CLI waits on with a way to its
* terminal (`waiting`) — and, like the terminal's status line, how long the
* turn has run and the output tokens it has made; a call running long shows
* its own time, and a line silent for minutes turns amber. A turn that ended
* closes with how long it took and its output.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -66,7 +73,7 @@
*
* ----------------------------------------------------------------------------- */
import { memo, useEffect, useState, useMemo } from "react";
import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
@@ -85,6 +92,9 @@ import {
Cog,
FileText,
Loader2,
Check,
Hand,
SquareTerminal,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -130,6 +140,7 @@ const SENDER_STYLES: Record<
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
@@ -140,6 +151,11 @@ interface MessageListProps {
onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
/** The CLI is asking the user something (a permission prompt): the live
* line says so instead of "Thinking…". */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -158,7 +174,7 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
/** The whole prompt a message was answered from: `input_tokens` is only its
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
@@ -205,12 +221,32 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
);
}
/** Compact pill for /command invocations parsed out of TUI markup. */
/** Typed text with its `/command` and `@path` tokens as chips, the rest linkified. */
function typedText(text: string): React.ReactNode[] {
return splitPrompt(text).map((tok, i) =>
tok.kind === "text" ? (
<Fragment key={i}>{linkify(tok.text)}</Fragment>
) : (
<span
key={i}
className={`rounded-md px-1.5 py-0.5 font-mono text-[0.92em] font-medium ${TOKEN_TONE[tok.kind]}`}
>
{tok.text}
</span>
)
);
}
/** Compact pill for /command invocations parsed out of TUI markup: the
* command in the prompt box's colour, its arguments after it. */
function CommandPill({ display }: { display: string }) {
const [name, ...args] = display.split(" ");
return (
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
<span className="text-status-success/70">›</span>
<span className="break-all">{display}</span>
<div className="inline-flex max-w-full items-baseline gap-2 rounded-lg border border-accent/25 bg-accent/10 px-2.5 py-1 font-mono text-sm">
<span className="font-semibold text-accent">{name}</span>
{args.length > 0 && (
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
)}
</div>
);
}
@@ -422,7 +458,8 @@ type Item =
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
| { kind: "done"; key: string; ms: number; output: number };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
@@ -431,9 +468,20 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
let reply: TranscriptMessage | null = null;
let output = outputCounter();
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
}
reply = null;
output = outputCounter();
};
messages.forEach((msg, idx) => {
const key = keys[idx]!;
@@ -443,6 +491,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") {
endTurn(key);
start = msg.timestamp ?? null;
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") {
@@ -465,6 +514,8 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
}
return items.push({ kind: "row", key, msg });
}
reply = msg;
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
@@ -553,13 +604,21 @@ function EditedFiles({
);
}
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
export function MessageList({
messages,
loading,
onOpenFile,
live = false,
waiting = false,
onOpenTerminal,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
@@ -609,9 +668,19 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
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>
</OpenFileContext.Provider>
);
@@ -621,88 +690,290 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
// written (a crash, a kill) — stop saying it works.
const IDLE_AFTER_MS = 15 * 60_000;
/** What the model is doing while its turn is not over: the call still
* waiting for its result (`block`), else thinking (`block: null`); `since`
* is that line's time. Null once the turn ended. */
/** What the model is doing while its turn is not over: the calls of the turn
* still waiting for their results (`blocks`, oldest first — parallel calls
* finish in any order), `since` the oldest one's time, `last` the newest
* line's; `blocks` is empty while it thinks. Null once the turn ended. */
export function workingOn(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null {
): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null {
const blocks: TranscriptContent[] = [];
let since: string | null = null;
let last: string | null = null;
// The newest line's verdict: undefined until it is found.
let alive: boolean | undefined;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
// A rename marker or a harness line says nothing about the turn.
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
const since = msg.timestamp ?? null;
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
const at = msg.timestamp ?? null;
const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
const newest = alive === undefined;
if (newest) last = at;
if (msg.type === "assistant") {
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
if (call?.id && !results.has(call.id)) return { block: call, since };
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
// "end_turn": over, whatever the transcript failed to pair.
if (newest && msg.stop_reason === "end_turn") return null;
for (const b of [...msg.content].reverse()) {
if (b.type === "tool_use" && b.id && !results.has(b.id)) {
blocks.unshift(b);
since = at;
}
}
// "tool_use": a call follows. None (an older server): over.
if (newest) alive = msg.stop_reason === "tool_use" && fresh;
continue;
}
// A prompt or a tool result: the model answers it next — unless the user stopped it.
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
if (text.startsWith("[Request interrupted")) return null;
return fresh ? { block: null, since } : null;
if (newest) alive = fresh;
// The prompt that opened the turn: nothing before it is this turn's.
if (text) break;
}
return null;
if (blocks.length) return { blocks, since, last };
return alive ? { blocks, since: null, last } : null;
}
/** Claude Desktop's live line: the step running now and for how long. */
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
/** Output tokens counted once per API call: Claude Code writes one line per
* content block, each repeating its call's usage — consecutive lines with
* the same prompt size are one call. */
function outputCounter() {
let total = 0;
let call: string | null = null;
let callOutput = 0;
return {
add(u: NonNullable<TranscriptMessage["usage"]>) {
const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`;
if (key !== call) {
total += callOutput;
call = key;
callOutput = 0;
}
callOutput = Math.max(callOutput, u.output_tokens || 0);
},
get total() {
return total + callOutput;
},
};
}
/** The live turn so far, as the terminal counts it: `start` is the prompt
* that opened it, `output` the output tokens of every API call since. */
export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } {
const output = outputCounter();
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
if (msg.type === "assistant") {
if (msg.usage) output.add(msg.usage);
continue;
}
const sender = msg.sender ?? "user";
if (sender === "user" && msg.content.some((c) => c.type === "text")) {
return { start: msg.timestamp ?? null, output: output.total };
}
}
return { start: null, output: output.total };
}
// A call running this long shows its own time beside the turn's.
const STEP_SHOWN_MS = 30_000;
// No new line for this long: the line turns amber — the step may be stuck.
const QUIET_MS = 3 * 60_000;
/** A number that counts up to its new value instead of jumping (none when
* the user asked for reduced motion). */
function useCountUp(value: number): number {
const [shown, setShown] = useState(value);
const shownRef = useRef(value);
useEffect(() => {
const from = shownRef.current;
if (from === value) return;
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduce || typeof requestAnimationFrame === "undefined") {
shownRef.current = value;
setShown(value);
return;
}
const t0 = performance.now();
let raf = 0;
const step = (t: number) => {
const k = Math.min(1, (t - t0) / 600);
shownRef.current = Math.round(from + (value - from) * k);
setShown(shownRef.current);
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [value]);
return shown;
}
/** A call's one-line title: its description (Claude Code's Bash
* `description`), else the tool and its target. */
function stepTitle(block: TranscriptContent): string {
const input = block.input as Record<string, unknown> | undefined;
return typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
}
/** The terminal's live line: the step running now (or the question the CLI
* waits on), then the turn's time and output tokens so far. */
function RunningStep({
blocks,
since,
last,
start,
tokens,
waiting,
onOpenTerminal,
}: {
blocks: TranscriptContent[];
since: string | null;
last: string | null;
start: string | null;
tokens: number;
waiting: boolean;
onOpenTerminal?: () => void;
}) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const input = block?.input as Record<string, unknown> | undefined;
const title = !block
? t("conversation.working")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
const counted = useCountUp(tokens);
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
const first = blocks[0];
const step = first ? age(since) : 0;
const quiet = !waiting && age(last) > QUIET_MS;
const title = waiting
? first
? t("conversation.waitingFor", { step: stepTitle(first) })
: t("conversation.waiting")
: first
? stepTitle(first)
: t("conversation.working");
const extra = [
start && formatMs(age(start)),
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
].filter(Boolean);
const amber = waiting || quiet;
return (
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
<span className="min-w-0 truncate">{title}</span>
{since && (
<span className="flex-shrink-0 font-mono text-xs">
{formatMs(now - parseDate(since).getTime())}
<div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{waiting ? (
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
) : (
<Loader2
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
/>
)}
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
{title}
</span>
{blocks.length > 1 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
aria-expanded={showAll}
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
>
{t("conversation.moreSteps", { count: blocks.length - 1 })}
</button>
)}
{step > STEP_SHOWN_MS && (
<span
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
>
{formatMs(step)}
</span>
)}
{extra.length > 0 && (
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
)}
{waiting && onOpenTerminal && (
<button
type="button"
data-testid="chat-open-terminal"
onClick={onOpenTerminal}
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
>
<SquareTerminal className="h-3.5 w-3.5" />
{t("conversation.openTerminal")}
</button>
)}
</div>
{showAll && blocks.length > 1 && (
<ul className="ml-5 space-y-0.5 text-xs">
{blocks.map((b, i) => (
<li key={b.id ?? i} className="truncate">
{stepTitle(b)}
</li>
))}
</ul>
)}
</div>
);
}
/** Under a turn that ended: how long it took and the output it made. */
function TurnDone({ ms, output }: { ms: number; output: number }) {
const { t } = useTranslation("sessions");
return (
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
<Check className="h-3 w-3 flex-shrink-0" />
{[
t("conversation.turnDone", { time: formatMs(ms) }),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
]
.filter(Boolean)
.join(" · ")}
</div>
);
}
const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached image: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
// What the prompt box sends for an attached file: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
const DROP_IMAGE =
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
const DROP_PATH =
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i;
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
/** A user turn's text without its dropped-image paths (and Claude Code's
* `[Image #N]` placeholders for them), and those images' names. */
export function splitDropImages(text: string): { text: string; images: string[] } {
/** A user turn's text without its dropped-file paths (and Claude Code's
* `[Image #N]` placeholders for them), the images' names and the other
* files' full paths. */
export function splitDropImages(text: string): {
text: string;
images: string[];
files: string[];
} {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
const files: string[] = [];
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
if (IMAGE_EXT.test(name)) images.push(name);
else files.push(full);
return "";
});
return {
text: images.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
text:
images.length || files.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
images,
files,
};
}
@@ -713,6 +984,30 @@ function imageSourcePaths(msg: TranscriptMessage): string[] | 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 }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
@@ -760,6 +1055,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
[msg.content]
);
const images = blocks.flatMap((b) => b?.images ?? []);
const files = blocks.flatMap((b) => b?.files ?? []);
const hasText = blocks.some((b) => b?.text);
return (
<div
@@ -767,7 +1063,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
{images.length > 0 && (
{images.length + files.length > 0 && (
<div
data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2"
@@ -775,6 +1071,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
{images.map((name) => (
<DropImage key={name} name={name} />
))}
{files.map((path) => (
<DropFile key={path} path={path} />
))}
</div>
)}
{hasText && (
@@ -792,7 +1091,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{linkify(stripAnsi(text).trim())}
{typedText(stripAnsi(text).trim())}
</p>
) : (
renderTextBlock(text, b)
@@ -1,11 +1,13 @@
/**
* @file ConversationView.notYet.test.tsx
* @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>
*/
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 { ConversationView } from "../ConversationView";
@@ -34,4 +36,23 @@ describe("ConversationView before a session's first hook", () => {
render(<ConversationView sessionId="gone" fill />);
expect(await screen.findByText("Session not found")).toBeTruthy();
});
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
const onOpenTerminal = vi.fn();
const onNotStarted = vi.fn();
render(
<ConversationView
sessionId="new"
fill
live
onOpenTerminal={onOpenTerminal}
onNotStarted={onNotStarted}
/>
);
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
expect(screen.queryByText("No conversation records found.")).toBeNull();
fireEvent.click(screen.getByText("Open terminal"));
expect(onOpenTerminal).toHaveBeenCalled();
expect(onNotStarted).toHaveBeenLastCalledWith(true);
});
});
@@ -66,6 +66,43 @@ describe("<MarkdownContent />", () => {
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
});
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
const { container } = render(
<MarkdownContent
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
});
it("highlights an ISO date-time as one number", () => {
const { container } = render(
<MarkdownContent
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual([
"2026-10-12T08:00",
"2026-10-12T08:00:00Z",
"2026-10-12T08:00:00.123+07:00",
]);
});
it("does not highlight numbers inside inline code", () => {
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
expect(container.querySelector("span.tabular-nums")).toBeNull();
});
it("colours a flag label by how serious it is", () => {
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
expect(screen.queryByText("Note")).toBeNull();
});
it("handles empty input safely", () => {
const { container } = render(<MarkdownContent text="" />);
// Wrapper exists but no block elements
@@ -2,8 +2,8 @@
* @file MessageList.images.test.tsx
* @description An image the prompt box attached reaches the transcript as its
* saved drop path; the user's turn shows the image (served by
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
* other path as typed. Claude Code turns a pasted drop path into an image
* `GET /api/run/drop/:name`), opens it full size on the page; any other
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
* URLs and absolute paths in a turn are links.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -19,7 +19,7 @@ describe("dropped images in a user turn", () => {
it("splitDropImages takes drop-dir image paths out of the text", () => {
expect(
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
@@ -28,16 +28,36 @@ describe("dropped images in a user turn", () => {
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
text: "fix this",
images: [SHOT],
files: [],
});
// Not a drop, or not an image: left as typed.
for (const text of [
"/home/me/shot.png",
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
]) {
expect(splitDropImages(text)).toEqual({ text, images: [] });
// Any other dropped file comes out by its full path.
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
// Not a drop: left as typed.
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
}
});
it("shows a dropped file as a chip that opens its preview", () => {
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `summarise ${pdf}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).not.toHaveTextContent("0123456789abcdef");
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
expect(opened).toEqual([pdf]);
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
@@ -4,12 +4,13 @@
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line.
* and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, workLabel, workingOn } from "../MessageList";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
}
describe("MessageList — sender attribution", () => {
it("shows a typed /command and @path as chips in the user's bubble", () => {
render(
<MessageList
messages={[
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
]}
loading={false}
/>
);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("/review @src/a.ts now");
expect(within(bubble).getByText("/review").className).toContain("text-accent");
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
});
it("labels each row by its sender, not blanket 'User'", () => {
const messages: TranscriptMessage[] = [
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
@@ -126,11 +142,17 @@ describe("MessageList — sender attribution", () => {
it("ends a live chat with the step still running", () => {
const messages = [
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
msg({
type: "user",
sender: "user",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [{ type: "text", text: "test it" }],
}),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(),
timestamp: new Date(Date.now() - 5_000).toISOString(),
usage: { input_tokens: 2, output_tokens: 3500 },
content: [
{
type: "tool_use",
@@ -144,7 +166,8 @@ describe("MessageList — sender attribution", () => {
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite");
expect(row).toHaveTextContent(/1m \ds/);
// Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
@@ -159,6 +182,98 @@ describe("MessageList — sender attribution", () => {
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
it("counts a turn's output once per API call, since its prompt", () => {
const at = "2026-06-26T08:19:00.000Z";
const line = (cache: number, out: number) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "x" }],
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
});
const messages = [
msg({ sender: "user" }),
line(10, 999), // an earlier turn
msg({ sender: "user", timestamp: at }),
line(100, 40),
line(100, 40), // the same call's second block
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
line(200, 25),
];
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
});
it("keeps every parallel call of the turn running until its own result", () => {
const at = "2026-06-26T08:19:00.000Z";
const now = Date.parse("2026-06-26T08:20:00.000Z");
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
});
const done = (id: string) =>
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
// Claude Code writes each parallel call on its own line; one result is in.
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
const results = new Map([["b", m[4]!.content[0]!]]);
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
// A call left unpaired in an EARLIER turn is not this one's.
const next = [
...m,
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
];
expect(workingOn(next, results, now)?.blocks).toEqual([]);
});
it("says when the CLI waits on the user, and offers the terminal", () => {
let opened = 0;
render(
<MessageList
loading={false}
live
waiting
onOpenTerminal={() => opened++}
messages={[
msg({ sender: "user", timestamp: new Date().toISOString() }),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date().toISOString(),
content: [
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
],
}),
]}
/>
);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
fireEvent.click(screen.getByTestId("chat-open-terminal"));
expect(opened).toBe(1);
});
it("closes an ended turn with its time and output", () => {
const reply = (out: number, stop_reason: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: "2026-06-26T08:16:08.000Z",
stop_reason,
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
content: [{ type: "text", text: "done" }],
});
render(
<MessageList
loading={false}
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
/>
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
});
it("knows what a live turn is doing until it ends", () => {
const now = Date.parse("2026-06-26T08:20:00.000Z");
const at = "2026-06-26T08:19:00.000Z";
@@ -189,10 +304,11 @@ describe("MessageList — sender attribution", () => {
);
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
const thinking = { blocks: [], since: null, last: at };
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
const stopped = msg({
+38 -33
View File
@@ -21,7 +21,7 @@
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip at the end of its first line (inline, so it covers no code),
* Chat chip floating above its last line, at the column where it ends,
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
@@ -50,6 +50,8 @@ import {
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type Tooltip,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
class ChatChip extends WidgetType {
constructor(
readonly start: number,
readonly end: number,
readonly build: (start: number, end: number) => HTMLElement
) {
super();
}
eq(o: ChatChip) {
return this.start === o.start && this.end === o.end;
}
toDOM() {
return this.build(this.start, this.end);
}
}
/** Add to Chat inline at the end of a non-empty selection's first line —
* the empty space right of the code, so it covers no line. `build` makes its
* DOM from the selection's first and last 1-based line. A selection ending
* at a line's start (a whole-line drag) does not take that line. */
/** Add to Chat floating just above the selection's last line, at the column
* where the selection ends — so a long line never pushes it out of view.
* `build` makes its DOM from the selection's first and last 1-based line. A
* selection ending at a line's start (a whole-line drag) does not take that
* line, and the chip then sits at the start of the last line it does take. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
if (!tr.docChanged && !tr.selection) return deco;
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
const { from, to, empty } = tr.state.selection.main;
if (empty) return Decoration.none;
if (empty) return null;
const doc = tr.state.doc;
const first = doc.lineAt(from);
const last = doc.lineAt(to);
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
const widget = new ChatChip(first.number, end, build);
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
const wholeLine = to === last.from && last.number > first.number;
const end = wholeLine ? last.number - 1 : last.number;
return {
pos: wholeLine ? doc.line(end).from : to,
above: true,
create: () => {
const dom = build(first.number, end);
dom.classList.add("cm-chat-chip");
return { dom };
},
};
},
provide: (f) => EditorView.decorations.from(f),
provide: (f) => showTooltip.from(f),
});
}
@@ -355,9 +350,19 @@ const theme = EditorView.theme({
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgb(var(--accent) / 0.3)",
// Doubled classes: drawSelection's base theme scopes its light/dark colours
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
// plain `.cm-selectionBackground` never outranks.
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
{
background: "rgb(var(--accent) / 0.3)",
},
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
border: "1px solid rgb(var(--accent) / 0.6)",
backgroundColor: "rgb(var(--surface-1))",
},
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${M} .cm-changedText`]: { background: "none" },
@@ -544,7 +549,7 @@ export default function DiffEditor({
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
@@ -1,26 +1,29 @@
/**
* @file A server-backed folder browser for the Add Lane modal's path inputs.
* Browsers refuse to expose an absolute filesystem path from a native folder
* @file A server-backed folder (or file) browser for the path inputs' Browse
* button. Browsers refuse to expose an absolute filesystem path from a native
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
* up-one-level button and a click-to-descend list, since this tool is
* local-first and the server already trusts arbitrary local paths.
* (immediate subdirectories of a path, plus its files for `kind="file"`)
* instead — breadcrumb-free, just an up-one-level button and a
* click-to-descend list; in file mode a click on a file picks it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
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 { useDialog } from "../../hooks/useDialog";
export function FolderBrowseModal({
open,
kind = "dir",
initialPath,
onSelect,
onClose,
}: {
open: boolean;
/** `file` lists files too, and picking one is the only way to select. */
kind?: "dir" | "file";
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
@@ -33,7 +36,7 @@ export function FolderBrowseModal({
const load = async (path?: string) => {
setError(null);
try {
setListing(await api.lanes.browse(path));
setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@@ -65,7 +68,7 @@ export function FolderBrowseModal({
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={t("browse.title")}
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
data-tour="folder-browse"
>
<div className="border-b border-border p-3">
@@ -94,10 +97,16 @@ export function FolderBrowseModal({
<button
key={entry.path}
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"
>
{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" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
@@ -118,17 +127,19 @@ export function FolderBrowseModal({
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
{kind === "dir" && (
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
)}
</div>
</div>
</div>
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
expect(b).not.toBeNull();
return b!;
});
// Inline at the end of the first selected line, never a tooltip over the
// line above it.
expect(chip.closest(".cm-tooltip")).toBeNull();
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
// A tooltip above the last selected line, never inline at a line's end
// (a long line pushed it out of view).
expect(chip.classList.contains("cm-tooltip")).toBe(true);
expect(chip.closest(".cm-line")).toBeNull();
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
@@ -171,8 +171,16 @@ export function OnboardingTour() {
style = { left: right + 16, top };
} else if (rect.bottom + 180 < window.innerHeight) {
style = { left, top: rect.bottom + 12 };
} else if (rect.top > 200) {
style = { left, top: rect.top - 12, transform: "translateY(-100%)" };
} else {
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
// A target filling the screen (the live session): no room above or
// below, so the card sits inside its bottom-right corner.
style = {
left: Math.min(Math.max(12, rect.right - W - 16), window.innerWidth - W - 12),
top: Math.min(rect.bottom, window.innerHeight) - 16,
transform: "translateY(-100%)",
};
}
}
+36 -7
View File
@@ -2,7 +2,8 @@
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane, laid out like
* Claude Desktop's activity panel: the session's Progress (its
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one — how many
* steps are done of how many, a bar, and the step in progress highlighted), the Outputs
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
* this session" — the files attached to its prompts, the files it read and the
* memory files it used and its skills (a click opens the list with a
@@ -18,6 +19,7 @@
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import {
BookOpen,
Check,
@@ -106,7 +108,8 @@ function Section({
children,
}: {
title: string;
count?: number;
/** Beside the title: a count, or a "3/7". */
count?: number | string;
action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */
grow?: boolean;
@@ -209,6 +212,8 @@ export function ChatOutline({
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const href = useSettingsHref();
const doneSteps = o.progress.filter((p) => p.status === "completed").length;
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
@@ -226,16 +231,40 @@ export function ChatOutline({
<aside
data-testid="chat-outline"
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.
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 && (
<Section title={t("chat.outline.progress")} action={close}>
<Section
title={t("chat.outline.progress")}
count={`${doneSteps}/${o.progress.length}`}
action={close}
>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={o.progress.length}
aria-valuenow={doneSteps}
aria-label={t("chat.outline.progress")}
className="mx-1.5 mb-1.5 h-1 overflow-hidden rounded-full bg-surface-3"
>
<div
className="h-full rounded-full bg-accent-solid transition-[width] duration-500"
style={{ width: `${(doneSteps / o.progress.length) * 100}%` }}
/>
</div>
{o.progress.map((task) => {
const done = task.status === "completed";
const now = task.status === "in_progress";
return (
<div key={task.id} className={rowClass} title={task.subject}>
<div
key={task.id}
className={`${rowClass} ${now ? "bg-accent/10 font-medium" : ""}`}
title={task.subject}
aria-current={now ? "step" : undefined}
>
{done ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
<Check className="h-3 w-3" />
@@ -329,7 +358,7 @@ export function ChatOutline({
icon={Plug}
label={t("chat.outline.connectors")}
names={o.mcp}
to="/settings/agents/claude-code?tab=mcp"
to={href("agents/claude-code?tab=mcp")}
/>
)}
</Section>
+67 -8
View File
@@ -28,7 +28,9 @@
* one, up to four panes). A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name.
* and `workspaceOf` names each tab's workspace before its own name. Settings
* opened on a workspace is one more tab at the bar's end (`settings`, kept
* until its X), shown instead of the panes while it is the active one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,8 +45,9 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import { useSettingsHref } from "../../lib/settingsLink";
import type { RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { toast } from "../../lib/toast";
@@ -152,6 +155,9 @@ interface Tab {
const URGENCY: RunState[] = ["awaiting", "error", "running"];
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
export function ConsoleTabs({
lane,
tabs: runs,
@@ -164,6 +170,7 @@ export function ConsoleTabs({
sessions,
onRenamed,
workspaceOf,
settings,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
@@ -187,9 +194,12 @@ export function ConsoleTabs({
onHome?: () => void;
/** Right end of the bar (the single pane's split menus). */
actions?: ReactNode;
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const settingsHref = useSettingsHref();
const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false);
// The tab being renamed (its run id) and the draft name.
@@ -397,8 +407,9 @@ export function ConsoleTabs({
};
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
const keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
const last = tabs.length - 1;
const last = keys.length - 1;
const to =
e.key === "ArrowRight"
? i === last
@@ -413,11 +424,14 @@ export function ConsoleTabs({
: e.key === "End"
? last
: null;
if (to === null || !tabs[to]) return;
const key = to === null ? undefined : keys[to];
if (key === undefined) return;
e.preventDefault();
tabRefs.current.get(tabs[to].key)?.focus();
tabRefs.current.get(key)?.focus();
};
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
const tabStop = settings?.active
? SETTINGS_KEY
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
// A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (tab: Tab) => unknown) => () => {
@@ -456,7 +470,7 @@ export function ConsoleTabs({
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
>
{tabs.map((tab, i) => {
const active = tab.index === groups.active;
const active = !settings?.active && tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const state = tabState(tab);
if (single && renaming === single.id) {
@@ -569,6 +583,48 @@ export function ConsoleTabs({
</div>
);
})}
{settings && (
<div
className={`group relative flex shrink-0 items-center rounded-t-md ${
settings.active
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
>
{settings.active && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
<button
type="button"
role="tab"
aria-selected={settings.active}
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(SETTINGS_KEY, el);
else tabRefs.current.delete(SETTINGS_KEY);
}}
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
data-testid="console-tab-settings"
onClick={settings.onOpen}
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
>
<Settings className="h-4 w-4 shrink-0" />
{t("tabs.settings")}
</button>
<button
type="button"
data-testid="console-tab-settings-close"
onClick={settings.onClose}
aria-label={t("tabs.closeSettings")}
title={t("tabs.closeSettings")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
settings.active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div ref={menuRef} className="relative flex items-center px-1.5">
<button
@@ -618,7 +674,10 @@ export function ConsoleTabs({
type="button"
role="menuitem"
className={`${item} !text-fg-muted`}
onClick={() => navigate("/settings/agents")}
onClick={() => {
setMenuOpen(false);
navigate(settingsHref("agents"));
}}
>
<Settings2 className="h-4 w-4" />
{t("tabs.agentSettings")}
+141 -18
View File
@@ -28,12 +28,22 @@
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
* conversation to its end and keeps it there (`follow`), so the message and
* the step running after it come into sight on their own. While the session's
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
* Stop in Send's place: Esc pressed in the pane (`interruptRun`). While the
* CLI asks the user something (the main agent awaiting input on a
* Notification — a permission prompt), the chat's live line says so and
* offers the terminal (`showTerminal`); before the session has started (no
* row yet: a trust prompt, say, answered only there) anything typed into the
* pane is confirmed first, so an Enter does not pick that prompt's
* preselected "No, exit" unasked. The box shows how full the context
* is (the newest reply's prompt size against the model's window,
* `contextWindow`), and a hidden browser tab is marked while the session
* waits on the user or once its turn ended (`lib/tabBadge.ts`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -48,11 +58,15 @@ import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select";
import { Divider } from "./PaneGrid";
import { Divider, showTerminal } from "./PaneGrid";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format";
import { agentAwaitingReason } from "../../lib/types";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
@@ -70,6 +84,58 @@ const storedSide = () => {
return f ? clampSide(f) : null;
};
/** The model's context window. Claude's 200K models are the 4.5 line and
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
export function contextWindow(model: string | null): number | null {
if (!model?.startsWith("claude-")) return null;
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
? 200_000
: 1_000_000;
}
/** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
const { t } = useTranslation("run");
const size = contextWindow(model);
if (!size || !tokens) return null;
const pct = Math.min(100, Math.round((tokens / size) * 100));
const tone =
pct >= 90 ? "text-status-danger" : pct >= 75 ? "text-status-warning" : "text-fg-muted";
const ring = 2 * Math.PI * 6;
return (
<span
data-testid="pane-chat-context"
title={t("chat.contextTitle", { used: fmt(tokens), size: fmt(size) })}
className={`flex items-center gap-1 px-1 text-xs tabular-nums ${tone}`}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 -rotate-90" aria-hidden>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={`${(pct / 100) * ring} ${ring}`}
/>
</svg>
{pct}%
</span>
);
}
/** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
const { t } = useTranslation(["run", "lanes"]);
@@ -160,20 +226,38 @@ export function PaneChat({
const [dragged, setDragged] = useState(storedSide);
const side = dragged ?? (preview ? 0.45 : 0.3);
const rootRef = useRef<HTMLDivElement>(null);
// The conversation's height, so a panel laid over a narrow pane stops above
// the prompt box instead of covering it.
const areaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const area = areaRef.current;
if (!area || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() =>
rootRef.current?.style.setProperty("--chat-area", `${area.offsetHeight}px`)
);
ro.observe(area);
return () => ro.disconnect();
}, []);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images);
// The main agent is "working" from a prompt's submit to its Stop hook.
// The main agent is "working" from a prompt's submit to its Stop hook, and
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false);
const [waiting, setWaiting] = useState(false);
const readWorking = useCallback(
() =>
api.agents
.list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its
// status filter ignored: check the status here.
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
.then((r) => {
const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working"));
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
[sessionId]
);
@@ -185,22 +269,56 @@ export function PaneChat({
// While Stop shows, also poll: a missed agent_updated (the hook reached
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working ? 5000 : undefined,
intervalMs: working || waiting ? 5000 : undefined,
match: (msg) =>
msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, waiting: false });
useEffect(() => {
if (waiting) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null);
else if (was.current.working || was.current.waiting) setTabBadge(runId, "done");
was.current = { working, waiting };
}, [runId, working, waiting]);
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.
const command = (line: string) => {
const command = async (line: string) => {
if (
notStarted &&
!(await confirmDialog({
title: t("chat.notStartedTitle"),
message: t("chat.notStarted"),
confirmLabel: t("chat.sendAnyway"),
}))
)
return false;
const ok = submitToRun(runId, line);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = () => {
const send = async () => {
if (!text || uploading) return;
if (!command(text)) return;
if (!(await command(text))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
@@ -215,12 +333,16 @@ export function PaneChat({
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
>
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
<div className="relative min-h-0 flex-1 overflow-hidden">
<div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
<ConversationView
sessionId={sessionId}
fill
live
follow={follow}
waiting={waiting}
onOpenTerminal={() => showTerminal(runId, sessionId)}
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={(path) => {
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
@@ -253,7 +375,7 @@ export function PaneChat({
pickFiles={pickFiles}
value={prompt}
onChange={setPrompt}
onSubmit={send}
onSubmit={() => void send()}
placeholder={t("chat.placeholder")}
rows={2}
slash={claude}
@@ -271,16 +393,17 @@ export function PaneChat({
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
<div className="ml-auto flex items-center gap-1">
<ContextMeter tokens={context.tokens} model={context.model} />
{claude && (
<ModelMenu
value={model}
onChange={(v) => {
if (command(`/model ${v || "default"}`)) setModel(v);
onChange={async (v) => {
if (await command(`/model ${v || "default"}`)) setModel(v);
}}
tool={run.tool}
effort={effort}
onEffortChange={(e) => {
if (command(`/effort ${e || "auto"}`)) setEffort(e);
onEffortChange={async (e) => {
if (await command(`/effort ${e || "auto"}`)) setEffort(e);
}}
/>
)}
@@ -303,7 +426,7 @@ export function PaneChat({
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
onClick={() => void send()}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
@@ -324,7 +447,7 @@ export function PaneChat({
scope={scope}
onClose={() => setPreview(null)}
// 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 && (
+23 -2
View File
@@ -58,6 +58,9 @@ import {
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -195,7 +198,18 @@ const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
* unsent prompt goes along: the terminal's input box into the chat's draft,
* the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
@@ -209,7 +223,14 @@ function ChatToggle({ run }: { run?: RunHandle }) {
aria-pressed={chat}
title={label}
aria-label={label}
onClick={() => setSessionChat(sessionId, !chat)}
onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
const draft = readChatDraft(sessionId);
const text = takeRunInput(run.id);
if (text)
writeChatDraft(sessionId, [draft.prompt, text].filter(Boolean).join("\n"), draft.images);
setSessionChat(sessionId, true);
}}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
{chat ? (
+74 -33
View File
@@ -12,6 +12,8 @@
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh;
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
@@ -61,6 +63,7 @@ import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
@@ -224,6 +227,15 @@ export function PromptEditor({
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
// With a `/command` or `@path` in it, the textarea's own text turns
// transparent and a mirror over it — same box, font and wrapping, scrolled
// with it — draws the text with those tokens coloured.
const mirrorRef = useRef<HTMLDivElement | null>(null);
const tokens = useMemo(() => promptTokens(value), [value]);
const marked = tokens.some((tok) => tok.kind !== "text");
const box = bare
? "block max-h-[40vh] w-full overflow-y-auto px-1 py-1 text-sm"
: "w-full rounded-md border px-3 py-2 text-[11px] font-mono";
// A composer grows with its text: reset to its rows, then take what the
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
useLayoutEffect(() => {
@@ -232,6 +244,10 @@ export function PromptEditor({
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [bare, value]);
// A mirror that just appeared starts at the top, whatever the textarea shows.
useLayoutEffect(() => {
if (mirrorRef.current && taRef.current) mirrorRef.current.scrollTop = taRef.current.scrollTop;
}, [marked]);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
@@ -492,7 +508,7 @@ export function PromptEditor({
) : (
<span
title={img.path}
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
className="flex h-14 w-40 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{img.name}</span>
@@ -545,39 +561,64 @@ export function PromptEditor({
}}
/>
)}
<textarea
ref={taRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
<div className="relative">
<textarea
ref={taRef}
onScroll={(e) => {
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
}}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}
}
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={
bare
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
}
/>
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
/>
{marked && (
<div
ref={mirrorRef}
aria-hidden
data-testid="prompt-mirror"
// Its own scrollbar keeps the width the textarea's takes, but is
// invisible, so the real one shows through.
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
>
{tokens.map((tok, i) =>
tok.kind === "text" ? (
tok.text
) : (
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
{tok.text}
</span>
)
)}
{/* A trailing newline is a line in a textarea, not in a div. */}
{"\u200b"}
</div>
)}
</div>
{upload && (
<p
role={upload === "failed" ? "alert" : "status"}
+83 -5
View File
@@ -14,7 +14,7 @@
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped on it are saved on the server
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with
@@ -37,7 +37,7 @@
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
import { useEffect, useId, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
@@ -184,11 +184,32 @@ 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
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
void attachFiles(Array.from(e.dataTransfer.files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
// text/plain and would paste nothing. Capture phase, because xterm's own
// paste listener stops the event. A Ctrl+V with no file in it is the pane's
// ^V again (vim's block select, readline's quoted insert), not a text paste.
const onPaste = (e: ClipboardEvent) => {
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0 && !ctrlV) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
if (files.length === 0) return;
setDrop("uploading");
try {
@@ -327,6 +348,18 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
// Ctrl+V: xterm would send ^V, and claude would then read the
// SERVER's clipboard, never this browser's. Let the browser paste
// instead (onPaste); a browser that fires none 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" && ev.shiftKey) ev.preventDefault();
return true;
@@ -486,6 +519,30 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert);
// Safari with macOS's own Vietnamese Telex types a word by REPLACING it:
// after the first letter every key fires `insertReplacementText` carrying
// the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards
// only `insertText`, and its keyCode-229 fallback snapshots the textarea
// after the change, so all but each word's first letter was lost (xterm.js
// #5887). Send the edit ourselves: a DEL per replaced char, then the tail.
let beforeReplace = "";
const onBeforeReplace = (e: Event) => {
if ((e as InputEvent).inputType === "insertReplacementText") {
beforeReplace = term.textarea?.value ?? "";
}
};
const onReplace = (e: Event) => {
if ((e as InputEvent).inputType !== "insertReplacementText") return;
const before = Array.from(beforeReplace);
const after = Array.from(term.textarea?.value ?? "");
let same = 0;
while (same < before.length && before[same] === after[same]) same++;
const edit = "\x7f".repeat(before.length - same) + after.slice(same).join("");
if (edit) term.input(edit, true);
};
term.textarea?.addEventListener("beforeinput", onBeforeReplace);
term.textarea?.addEventListener("input", onReplace);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
@@ -514,6 +571,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
term.textarea?.removeEventListener("beforeinput", onBeforeReplace);
term.textarea?.removeEventListener("input", onReplace);
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
@@ -562,6 +621,24 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
(data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
},
// The rows on screen, dim cells (a TUI's placeholder) left out.
() => {
const term = termRef.current;
if (!term) return null;
const buf = term.buffer.active;
const rows: string[] = [];
for (let y = 0; y < term.rows; y++) {
const line = buf.getLine(buf.baseY + y);
let row = "";
for (let x = 0; line && x < line.length; x++) {
const cell = line.getCell(x);
// Width 0: the right half of a wide character.
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
}
rows.push(row);
}
return { rows, cols: term.cols };
}
),
[runId]
@@ -616,7 +693,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
onDrop={onDrop}
onPasteCapture={onPaste}
onFocus={() => notePaneFocus(runId)}
>
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
);
expect(screen.getByText("Parse plan").className).toContain("line-through");
expect(screen.getByText("Build report").className).not.toContain("line-through");
// How far the plan is, and which step is being worked on.
expect(screen.getByText("1/2")).toBeTruthy();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step");
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
@@ -14,20 +14,30 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat } from "../PaneChat";
import { PaneChat, contextWindow } from "../PaneChat";
import { api } from "../../../lib/api";
import { interruptRun, submitToRun } from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file.
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
ConversationView: ({
onOpenFile,
onNotStarted,
}: {
onOpenFile?: (path: string) => void;
onNotStarted?: (notStarted: boolean) => void;
}) => (
<div data-testid="conversation">
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
{/* The session has no row yet: the CLI has not started it. */}
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
</div>
),
}));
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
@@ -200,6 +210,44 @@ describe("PaneChat — Stop", () => {
});
});
describe("PaneChat — waiting on the user", () => {
it("marks a hidden tab while a permission prompt waits", async () => {
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
document.title = "Workspace · AgentHub";
try {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [
{
type: "main",
status: "waiting",
awaiting_input_since: "2026-10-12T08:00:00.000Z",
awaiting_reason: "notification",
},
],
} as never);
const { unmount } = renderChat();
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
unmount();
expect(document.title).toBe("Workspace · AgentHub");
} finally {
vis.mockRestore();
}
});
});
describe("contextWindow", () => {
it("knows Claude's 200K line from the 1M one", () => {
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
expect(contextWindow("gpt-5-codex")).toBeNull();
expect(contextWindow(null)).toBeNull();
});
});
describe("PaneChat — model, effort and permission mode", () => {
it("types /model into the pane", async () => {
renderChat();
@@ -333,4 +381,34 @@ describe("PaneChat — the unsent prompt", () => {
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
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();
});
});
@@ -5,7 +5,7 @@
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path (and
* remembered as a recent upload), a Files panel row dropped as an `@path`
* chip, a composer
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
@@ -16,7 +16,7 @@
*/
import { useState } from "react";
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import {
@@ -117,6 +117,31 @@ function renderEditor(value: string, slash = true) {
const box = () => screen.getByPlaceholderText("Ask anything…");
describe("PromptEditor — / and @ suggestions", () => {
it("colours /command and @path tokens through a mirror, only when there are any", () => {
const { unmount } = render(
<PromptEditor
value={"/review @src/a.ts please"}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
onChange={() => {}}
onSubmit={() => {}}
/>
);
const mirror = screen.getByTestId("prompt-mirror");
expect(mirror).toHaveAttribute("aria-hidden");
expect(within(mirror).getByText("/review").className).toContain("text-accent");
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
expect(box().className).toContain("text-transparent");
unmount();
renderEditor("plain words");
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
expect(box().className).toContain("text-fg-primary");
});
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const spies = renderEditor("");
fireEvent.change(box(), { target: { value: "/rev" } });
@@ -413,6 +438,8 @@ describe("PromptEditor — newlines and images", () => {
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
@@ -2,7 +2,8 @@
* @file TerminalView.test.tsx
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* mocked terminal, forwards typed input (Safari Telex word replacements
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching.
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
@@ -69,6 +73,8 @@ vi.mock("@xterm/xterm", () => ({
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
input: inputMock,
textarea: (textarea = document.createElement("textarea")),
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})),
}));
@@ -503,4 +509,71 @@ 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.
it("lets Ctrl+V reach the browser's paste, and still sends ^V when it holds no file", () => {
inputMock.mockClear();
pasteMock.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);
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(pasteMock).not.toHaveBeenCalled();
});
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],
]);
});
});
@@ -0,0 +1,171 @@
/**
* @file AgentInstructionsSettings — Settings → AI tools → My agent
* instructions (issue #67): the account's own rules in markdown, put before
* every workspace's own instructions in each new Claude or Codex session of a
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
* failed load says so with a Retry instead of leaving a dead, empty editor —
* the editor stays read-only until the saved text is known, so a save can
* never overwrite instructions it did not load. **Choose file** reads a local
* Markdown/text file in the browser into the draft (nothing is uploaded until
* Save, and Discard brings the saved text back). "Applies to" lists only the
* installed, enabled tools whose panes the server says receive them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { FileUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api, type AgentInstructionsView } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { toast } from "../../lib/toast";
import { SettingsSection } from "./SettingsRow";
export function AgentInstructionsSettings() {
const { t } = useTranslation("settings");
const [saved, setSaved] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [max, setMax] = useState<number | undefined>();
const [toolIds, setToolIds] = useState<string[]>([]);
const { tools } = useAiTools();
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const fileInput = useRef<HTMLInputElement | null>(null);
const apply = (r: AgentInstructionsView) => {
setSaved(r.instructions);
setDraft(r.instructions);
setMax(r.max);
setToolIds(r.tools ?? []);
};
const load = () => {
setLoadError(null);
api.me
.instructions()
.then(apply)
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
};
useEffect(load, []);
async function save() {
setSaving(true);
try {
apply(await api.me.saveInstructions(draft));
toast.success(t("agentInstructions.saved"));
} catch (err) {
toast.error(
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
);
} finally {
setSaving(false);
}
}
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again after a Discard
if (!file) return;
const text = await file.text();
if (max && text.length > max) {
toast.error(
t("agentInstructions.fileTooBig", {
name: file.name,
size: text.length.toLocaleString(),
max: max.toLocaleString(),
})
);
return;
}
setDraft(text);
}
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
const ready = saved !== null;
const dirty = ready && draft !== saved;
return (
<SettingsSection
id="agent-instructions"
title={t("agentInstructions.title")}
description={t("agentInstructions.description")}
>
<div className="pt-1">
{appliesTo.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
<span>{t("agentInstructions.appliesTo")}</span>
{appliesTo.map((x) => (
<span
key={x.id}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
>
<AiToolIcon tool={x} className="h-4 w-4" />
{x.label}
</span>
))}
</div>
)}
<textarea
aria-label={t("agentInstructions.title")}
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
rows={8}
spellCheck={false}
placeholder={t("agentInstructions.placeholder")}
value={draft}
maxLength={max}
disabled={!ready}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="mt-3 flex items-center justify-between gap-3">
{loadError ? (
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
<span className="truncate">
{t("agentInstructions.loadFailed", { message: loadError })}
</span>
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
{t("common:retry")}
</button>
</p>
) : (
<span className="text-[11px] tabular-nums text-fg-muted">
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
</span>
)}
<div className="flex shrink-0 items-center gap-2">
<input
ref={fileInput}
type="file"
accept=".md,.markdown,.txt,text/markdown,text/plain"
className="hidden"
data-testid="agent-instructions-file"
onChange={(e) => void pickFile(e)}
/>
<button
type="button"
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
disabled={!ready || saving}
onClick={() => fileInput.current?.click()}
>
<FileUp className="h-3.5 w-3.5" />
{t("agentInstructions.chooseFile")}
</button>
{dirty && !saving && (
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
{t("agentInstructions.discard")}
</button>
)}
<button
type="button"
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
disabled={saving || !dirty}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
</div>
</div>
</SettingsSection>
);
}
@@ -20,6 +20,7 @@ import { Checkbox } from "../Checkbox";
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import {
ArrowRight,
DollarSign,
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
*/
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
const { t } = useTranslation("settings");
const href = useSettingsHref();
return (
<div className="card flex items-center justify-between gap-4 p-5">
<div className="min-w-0">
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
</div>
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
<Link to={href(`pricing#pricing-${vendor}`)} className="btn-ghost shrink-0 text-xs">
{t("pricingLink.open")}
<ArrowRight className="h-3.5 w-3.5" />
</Link>
@@ -0,0 +1,96 @@
/**
* @file AgentInstructionsSettings.test.tsx
* @description My agent instructions: a failed load shows the error with a
* Retry (the editor stays locked, so a save cannot overwrite unloaded text)
* and Retry unlocks it; Choose file puts a local file's text into the draft
* without saving, and refuses one longer than the limit; "Applies to" names
* only the installed, enabled tools the server says receive them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AgentInstructionsSettings } from "../AgentInstructionsSettings";
import { api } from "../../../lib/api";
import { toast } from "../../../lib/toast";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { me: { instructions: vi.fn(), saveInstructions: vi.fn() } },
};
});
const aiTools = vi.hoisted(() => ({
tools: [] as { id: string; label: string; installed: boolean; enabled: boolean }[],
}));
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const instructions = vi.mocked(api.me.instructions);
const saveInstructions = vi.mocked(api.me.saveInstructions);
const editor = () => screen.getByRole("textbox") as HTMLTextAreaElement;
// jsdom's File has no text(); every browser AgentHub supports does.
const pick = (text: string, name = "rules.md") => {
const file = Object.assign(new File([text], name, { type: "text/markdown" }), {
text: () => Promise.resolve(text),
});
fireEvent.change(screen.getByTestId("agent-instructions-file"), { target: { files: [file] } });
};
describe("AgentInstructionsSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
aiTools.tools = [];
});
it("names only the installed, enabled tools that receive the instructions", async () => {
aiTools.tools = [
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
{ id: "codex", label: "Codex", installed: true, enabled: false },
{ id: "cursor", label: "Cursor CLI", installed: true, enabled: true },
];
instructions.mockResolvedValue({ instructions: "", max: 100, tools: ["claude", "codex"] });
render(<AgentInstructionsSettings />);
expect(await screen.findByText("Claude Code")).toBeInTheDocument();
expect(screen.getByText("Applies to")).toBeInTheDocument();
expect(screen.queryByText("Codex")).toBeNull();
expect(screen.queryByText("Cursor CLI")).toBeNull();
});
it("shows a failed load with Retry, and Retry unlocks the editor", async () => {
instructions.mockRejectedValueOnce(new Error("HTTP 404"));
render(<AgentInstructionsSettings />);
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
expect(editor()).toBeDisabled();
instructions.mockResolvedValueOnce({ instructions: "# saved", max: 100, tools: [] });
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => expect(editor()).not.toBeDisabled());
expect(editor().value).toBe("# saved");
});
it("Choose file fills the draft without saving; Save sends it", async () => {
instructions.mockResolvedValue({ instructions: "", max: 100, tools: [] });
saveInstructions.mockResolvedValue({ instructions: "# from file", max: 100, tools: [] });
render(<AgentInstructionsSettings />);
await waitFor(() => expect(editor()).not.toBeDisabled());
pick("# from file");
await waitFor(() => expect(editor().value).toBe("# from file"));
expect(saveInstructions).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(saveInstructions).toHaveBeenCalledWith("# from file"));
});
it("refuses a file longer than the limit and keeps the draft", async () => {
instructions.mockResolvedValue({ instructions: "keep", max: 5, tools: [] });
render(<AgentInstructionsSettings />);
await waitFor(() => expect(editor()).not.toBeDisabled());
pick("way too long");
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(editor().value).toBe("keep");
});
});
+2 -1
View File
@@ -43,7 +43,6 @@
"browse.fileTitle": "Browse for a file",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"browse.typePath": "No native picker is available here — type the path instead.",
"changes.back": "All files",
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
@@ -384,6 +383,8 @@
"tabs.close": "Close tab (stops its session)",
"tabs.full": "All 10 sessions are in use — stop one first",
"tabs.agentSettings": "Agent settings…",
"tabs.settings": "Settings",
"tabs.closeSettings": "Close Settings",
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
"panes.pick": "Show another session in this pane",
+8
View File
@@ -86,6 +86,9 @@
"placeholder": "Write a message…",
"send": "Send",
"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",
"notStopped": "The terminal is not connected — nothing was stopped.",
"add": "Add files, skills…",
@@ -119,6 +122,11 @@
"close": "Close",
"noPreview": "No preview for this file here",
"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"
}
},
"interrupted": {
+11 -1
View File
@@ -143,6 +143,8 @@
"loading": "Loading conversation…",
"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.",
"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",
"systemReminder": "System reminder",
"persistedOutput": "Persisted output",
@@ -199,7 +201,15 @@
},
"edited_one": "Edited {{count}} file",
"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}}",
"turnDone": "Done in {{time}}"
},
"overview": {
"agent": "Agent",
+12
View File
@@ -1032,5 +1032,17 @@
"label": "Open new sessions as a chat",
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"saveFailed": "Could not save: {{message}}"
},
"agentInstructions": {
"title": "My agent instructions",
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
"placeholder": "# How I work\n- Answer in Vietnamese\n- Keep diffs small; run the tests before finishing",
"saved": "Instructions saved — new sessions will use them",
"saveFailed": "Could not save: {{message}}",
"loadFailed": "Could not load your instructions: {{message}}",
"discard": "Discard",
"chooseFile": "Choose file",
"fileTooBig": "{{name}} is {{size}} characters — instructions hold at most {{max}}",
"appliesTo": "Applies to"
}
}
+2 -1
View File
@@ -43,7 +43,6 @@
"browse.fileTitle": "Duyệt chọn tệp",
"browse.select": "Chọn thư mục này",
"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.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
@@ -384,6 +383,8 @@
"tabs.close": "Đóng tab (dừng phiên của nó)",
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
"tabs.agentSettings": "Cài đặt agent…",
"tabs.settings": "Cài đặt",
"tabs.closeSettings": "Đóng Cài đặt",
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
"panes.pick": "Hiện session khác trong pane này",
+8
View File
@@ -86,6 +86,9 @@
"placeholder": "Viết tin nhắn…",
"send": "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",
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
"add": "Thêm file, skill…",
@@ -119,6 +122,11 @@
"close": "Đóng",
"noPreview": "Không xem trước được tệp này ở đây",
"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"
}
},
"interrupted": {
+11 -1
View File
@@ -143,6 +143,8 @@
"loading": "Đang tải 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.",
"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",
"systemReminder": "Nhắc nhở hệ thống",
"persistedOutput": "Đầu ra đã lưu",
@@ -199,7 +201,15 @@
},
"edited_one": "Đã 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}}",
"turnDone": "Xong trong {{time}}"
},
"overview": {
"agent": "Agent",
+12
View File
@@ -1032,5 +1032,17 @@
"label": "Mở session mới dạng chat",
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"saveFailed": "Không lưu được: {{message}}"
},
"agentInstructions": {
"title": "Hướng dẫn agent của tôi",
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
"placeholder": "# Cách tôi làm việc\n- Trả lời bằng tiếng Việt\n- Diff nhỏ; chạy test trước khi xong",
"saved": "Đã lưu — các session mới sẽ dùng nội dung này",
"saveFailed": "Không lưu được: {{message}}",
"loadFailed": "Không tải được hướng dẫn: {{message}}",
"discard": "Bỏ thay đổi",
"chooseFile": "Chọn file",
"fileTooBig": "{{name}} dài {{size}} ký tự — hướng dẫn tối đa {{max}}",
"appliesTo": "Áp dụng cho"
}
}
+25
View File
@@ -534,6 +534,31 @@
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
reads it; no typography plugin. */
.md-preview {
+59 -2
View File
@@ -4,12 +4,38 @@
* focused last, else any of its panes, never another workspace's (lane 1 is
* not lane 12), nowhere once the pane unmounted, and never into a pane that
* 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 moved out of it (`takeRunInput`) when the pane switches to Chat.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
import {
interruptRun,
notePaneFocus,
parsePromptInput,
pasteToLane,
registerPane,
takeRunInput,
} from "../panePaste";
const RULE = "─".repeat(60);
// Claude Code at 60 columns: a wrapped first line, then a typed newline. Its
// box draws `❯` and a NO-BREAK space (U+00A0), never a plain one.
const CLAUDE = [
"❯ Use the Bash tool to run: sleep 20",
"",
RULE,
"❯\u00a0alpha beta gamma delta epsilon zeta eta theta iota kappa",
" lambda mu nu xi omicron pi rho sigma tau",
" Tiếng Việt có dấu",
RULE,
" Model: Haiku 5.5",
" ⏵⏵ auto mode on (shift+tab to cycle)",
];
// Codex at 60 columns (its dim placeholder already left out of an empty box).
const CODEX = ["› earlier message", "", "› alpha beta", " indented", "", " GPT-6 · /tmp", ""];
describe("panePaste", () => {
it("pastes into the workspace's last-focused pane, and only its own", () => {
@@ -65,4 +91,35 @@ describe("panePaste", () => {
expect(keys).toEqual(["\x1b"]);
off();
});
it("reads claude's and codex's input box, joining soft-wrapped rows back", () => {
expect(parsePromptInput(CLAUDE, 60)).toBe(
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau\nTiếng Việt có dấu"
);
expect(parsePromptInput(CODEX, 60)).toBe("alpha beta\n indented");
expect(parsePromptInput([RULE, "❯\u00a0", RULE, " ⏵⏵ auto mode on"], 60)).toBe("");
expect(parsePromptInput(["› ", "", " GPT-6 · /tmp", " ← for agents"], 60)).toBe("");
// A menu or permission prompt in the box's place: nothing to read.
expect(parsePromptInput(["Do you want to proceed?", "❯ 1. Yes", " 2. No"], 60)).toBeNull();
});
it("takeRunInput deletes what it read, and leaves a paste placeholder alone", () => {
const keys: string[] = [];
let rows = CODEX;
const off = registerPane(
"agenthub-lane-7",
() => {},
() => true,
(d) => keys.push(d),
() => ({ rows, cols: 60 })
);
expect(takeRunInput("agenthub-lane-7")).toBe("alpha beta\n indented");
const n = "alpha beta\n indented".length + 1;
expect(keys).toEqual(["\x1b[3~".repeat(n) + "\x7f".repeat(n)]);
rows = [RULE, "❯ [Pasted text #1 +40 lines] and more", RULE];
expect(takeRunInput("agenthub-lane-7")).toBe("");
expect(keys).toHaveLength(1);
off();
expect(takeRunInput("agenthub-lane-7")).toBe("");
});
});
@@ -0,0 +1,43 @@
/**
* @file Tests for promptTokens: `/command` and `@path` at a word start become
* tokens; paths, e-mails, mid-word slashes and trailing punctuation do not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { promptTokens } from "../promptTokens";
const kinds = (s: string) => promptTokens(s).map((t) => `${t.kind}:${t.text}`);
describe("promptTokens", () => {
it("splits commands and mentions out of the text", () => {
expect(kinds("/review @src/a.ts now")).toEqual([
"slash:/review",
"text: ",
"mention:@src/a.ts",
"text: now",
]);
expect(kinds("run /agenthub:file-headers\nok")).toEqual([
"text:run ",
"slash:/agenthub:file-headers",
"text:\nok",
]);
});
it("leaves paths, e-mails and mid-word slashes as text", () => {
expect(kinds("see /home/x and/or me@x.io")).toEqual(["text:see /home/x and/or me@x.io"]);
});
it("drops trailing prose punctuation from a mention", () => {
expect(kinds("read @a.ts, then")).toEqual(["text:read ", "mention:@a.ts", "text:, then"]);
});
it("keeps the text whole, so it can be laid over the input", () => {
const s = " /x @y.\n@z ";
expect(
promptTokens(s)
.map((t) => t.text)
.join("")
).toBe(s);
});
});
@@ -0,0 +1,35 @@
/**
* @file Tests for settingsHref: the Settings page off a workspace, the
* workspace's Settings tab on one (lane and run kept, a replay and a previous
* sub-tab dropped, the section's own query and anchor carried).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { settingsHref } from "../settingsLink";
describe("settingsHref", () => {
it("opens the Settings page off a workspace", () => {
const at = { pathname: "/sessions", search: "" };
expect(settingsHref("", at)).toBe("/settings");
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
"/settings/agents/claude-code?tab=mcp"
);
expect(settingsHref("appearance#pet", at)).toBe("/settings/appearance#pet");
// The Workspace page with no workspace yet has no tab bar to open it in.
expect(settingsHref("agents", { pathname: "/workspace", search: "" })).toBe("/settings/agents");
});
it("opens the workspace's Settings tab on a workspace", () => {
const at = { pathname: "/workspace", search: "?lane=3&run=agenthub-lane-3-1&tab=skills" };
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
"/workspace?lane=3&run=agenthub-lane-3-1&settings=agents%2Fclaude-code&tab=mcp"
);
expect(settingsHref("", { pathname: "/workspace", search: "?lane=3&session=abc" })).toBe(
"/workspace?lane=3&settings="
);
expect(
settingsHref("pricing#pricing-openai", { pathname: "/workspace", search: "?lane=3" })
).toBe("/workspace?lane=3&settings=pricing#pricing-openai");
});
});
+25 -4
View File
@@ -692,6 +692,14 @@ export const api = {
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
instructions: () => request<AgentInstructionsView>("/me/instructions"),
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
saveInstructions: (instructions: string) =>
request<AgentInstructionsView>("/me/instructions", {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
},
@@ -2962,14 +2970,20 @@ export const api = {
* the Add Lane modal's folder browser. Browsers cannot expose absolute
* 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 files List files too (`file: true`) — a file field's picker.
* @returns `{ path, parent, entries }` — `parent` is null at the root.
*/
browse: (path?: string) =>
request<{
browse: (path?: string, files?: boolean) => {
const q = new URLSearchParams();
if (path) q.set("path", path);
if (files) q.set("files", "1");
const qs = q.toString();
return request<{
path: string;
parent: string | null;
entries: { name: string; path: string; isGitRepo: boolean }[];
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
entries: { name: string; path: string; isGitRepo: boolean; file?: boolean }[];
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
},
/**
* 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
@@ -3566,6 +3580,13 @@ export interface GitAuthNeeded {
}
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
/** GET/PUT /api/me/instructions — `tools`: the AI tool ids whose new panes receive them. */
export interface AgentInstructionsView {
instructions: string;
max: number;
tools: string[];
}
export interface AiTool {
id: string;
label: string;
+71 -4
View File
@@ -10,27 +10,35 @@
* unseen); the caller says where it went, or that it went nowhere. The one
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
* what the user typed there, so it pastes AND presses Enter — and its Stop
* button presses Esc through `interruptRun`.
* button presses Esc through `interruptRun`. Switching a pane between Chat and
* 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
* without Enter), so the half-typed text is never left behind on the other side.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** The terminal's screen: one string per row, dim cells (a placeholder) left out. */
export type PaneScreen = { rows: string[]; cols: number };
interface Pane {
paste: (text: string, submit?: boolean) => void;
visible: () => boolean;
key?: (data: string) => void;
screen?: () => PaneScreen | null;
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
* raw input, as a key press); returns its unregister. */
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
export function registerPane(
runId: string,
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true,
key?: (data: string) => void
key?: (data: string) => void,
screen?: () => PaneScreen | null
): () => void {
const entry = { paste, visible, key };
const entry = { paste, visible, key, screen };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === entry) panes.delete(runId);
@@ -84,3 +92,62 @@ export function pasteToLane(laneId: number, text: string): string | null {
panes.get(id)!.paste(text);
return id;
}
const BORDER = /^─{3,}$/;
/** The text in a `claude` or `codex` input box on `rows`, or null when no box
* is shown (a menu, a permission prompt). Claude's box is the `❯` row under a
* `─` rule, up to the next rule; Codex's the last `›` row, up to the blank row
* above its footer. Both indent a continued row by two and wrap at word
* boundaries, so a row whose first word would have fitted on the one above
* starts a new line — a soft-wrapped row is joined back with its space. */
export function parsePromptInput(rows: string[], cols: number): string | null {
const r = rows.map((l) => l.trimEnd());
let p = -1;
let end = -1;
for (let i = r.length - 1; i >= 0 && p < 0; i--) {
const row = r[i] ?? "";
// Claude draws `❯` + a no-break space (U+00A0) in its box.
if (/^❯([ \u00a0]|$)/.test(row) && BORDER.test(r[i - 1]?.trim() ?? "")) {
p = i;
end = r.findIndex((l, j) => j > i && BORDER.test(l.trim()));
} else if (/^›( |$)/.test(row)) {
p = i;
let f = r.length - 1;
while (f > i && !r[f]) f--;
while (f > i && r[f]) f--;
end = f > i ? f : -1;
}
}
if (p < 0 || end < 0) return null;
const lines = r.slice(p, end).map((l) => l.slice(2));
while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
const width = cols - 2;
let out = "";
let prev: string | null = null;
for (const line of lines) {
const word = line.split(" ")[0] ?? "";
// The first row; a word longer than the row, broken mid-word; one that
// would not fit after the row above, wrapped; else a typed newline.
if (prev !== null)
out += prev.length >= width ? "" : word && prev.length + 1 + word.length > width ? " " : "\n";
out += line;
prev = line;
}
return out;
}
/** Moves the text out of `runId`'s input box: returns it and deletes it there
* (Delete then Backspace, as many as it has characters — never Ctrl+C or
* Esc, which stop a working turn). "" when no box can be read, it is empty,
* or it holds what the screen cannot show back (`[Pasted text #1]`,
* `[Image #1]`) — then it is left as it is. */
export function takeRunInput(runId: string): string {
const p = panes.get(runId);
const screen = p?.visible() && p.key ? p.screen?.() : null;
const text = screen ? parsePromptInput(screen.rows, screen.cols) : null;
if (!text?.trim() || /\[(Pasted text|Image) #\d+/.test(text)) return "";
const n = text.length + 1;
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
return text;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* @file promptTokens — splits a prompt into plain text, `/command` and
* `@path` tokens, so the prompt box and a sent message colour them the same
* way. A token starts a word (the line start or after whitespace), like the
* prompt box's autocomplete, so `a/b`, `me@x.io` and `/home/x` stay text.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export type PromptToken = { kind: "text" | "slash" | "mention"; text: string };
/** A token's colours, shared by the prompt box and a sent message; `@path`
* takes the chat's path-link violet. */
export const TOKEN_TONE = {
slash: "bg-accent/15 text-accent",
mention: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
} as const;
// `/name` (plugin skills are `plugin:name`) ending the word; `@path` minus a
// trailing full stop or comma, which is prose.
const TOKEN_RE = /(?<=^|\s)(?:\/[\w:.-]+(?=\s|$)|@[^\s]*[^\s.,;:!?)])/g;
export function promptTokens(text: string): PromptToken[] {
const out: PromptToken[] = [];
let at = 0;
for (const m of text.matchAll(TOKEN_RE)) {
if (m.index > at) out.push({ kind: "text", text: text.slice(at, m.index) });
out.push({ kind: m[0][0] === "/" ? "slash" : "mention", text: m[0] });
at = m.index + m[0].length;
}
if (at < text.length) out.push({ kind: "text", text: text.slice(at) });
return out;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @file settingsLink — where a Settings link opens. On a workspace (the
* Workspace page with `?lane=`) it is that page's Settings tab
* (`?settings=<section>`, keeping `lane` and `run`), so the terminals and chats
* stay mounted behind it; anywhere else it is the Settings page
* (`/settings/<section>`). A path is what the page takes after `/settings/`:
* `<section>[?query][#anchor]`, `""` for the default section.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useLocation } from "react-router-dom";
export function settingsHref(path: string, at: { pathname: string; search: string }): string {
const [rest = "", anchor = ""] = path.split("#");
const [section = "", query = ""] = rest.split("?");
const hash = anchor ? `#${anchor}` : "";
const here = new URLSearchParams(at.search);
const lane = here.get("lane");
if (at.pathname !== "/workspace" || !lane) {
return `/settings${section ? `/${section}` : ""}${query ? `?${query}` : ""}${hash}`;
}
// A section's own query (a sub-tab) and the session replay do not carry over.
const next = new URLSearchParams({ lane });
const run = here.get("run");
if (run) next.set("run", run);
next.set("settings", section);
for (const [k, v] of new URLSearchParams(query)) next.set(k, v);
return `/workspace?${next}${hash}`;
}
/** `settingsHref` from the current location. */
export function useSettingsHref(): (path: string) => string {
const location = useLocation();
return (path) => settingsHref(path, location);
}
+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 -1
View File
@@ -16,6 +16,7 @@
import { useEffect, useState, type ComponentType } from "react";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api";
@@ -235,6 +236,7 @@ function Card({
/** The tab for one catalog tool. */
export function AiToolConfig({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const href = useSettingsHref();
const aiTools = useAiTools();
const tool = aiTools.byId(toolId);
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
@@ -273,7 +275,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
</a>
)}
{tool.update && (
<Link to="/settings/agents" className="btn-ghost text-xs">
<Link to={href("agents")} className="btn-ghost text-xs">
<RefreshCw className="h-3.5 w-3.5" />
{t("toolTab.update")}
</Link>
+5 -1
View File
@@ -88,6 +88,7 @@ import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
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
// 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(() => {
if (!seesAll) return;
api.remoteSources
.list()
.then((res: { sources: RemoteSource[] }) => {
setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label])));
})
.catch(() => undefined);
}, []);
}, [seesAll]);
useEffect(() => {
loadSourceLabels();
+7 -5
View File
@@ -196,7 +196,7 @@ export function Settings({
section?: "data" | "about" | "notifications";
} = {}) {
const { t } = useTranslation("settings");
const { isAdmin } = usePermissions();
const { isAdmin, seesAll } = usePermissions();
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -954,10 +954,12 @@ export function Settings({
<ImportHistory />
</section>
{/* ─── REMOTE DATA SOURCES ─── */}
<section id="remote-sources" className="scroll-mt-24">
<RemoteSources />
</section>
{/* ─── REMOTE DATA SOURCES (staff only: the server answers 403 to a developer) ─── */}
{seesAll && (
<section id="remote-sources" className="scroll-mt-24">
<RemoteSources />
</section>
)}
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings />
+46 -23
View File
@@ -20,6 +20,10 @@
* marks AI tools current, the search shows their matches under it), and a
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* With `section` it is a workspace's Settings tab instead (`?settings=` on
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
* while the tab is hidden behind a session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
@@ -46,6 +50,7 @@ import {
Server,
} from "lucide-react";
import { Settings } from "./Settings";
import { useSettingsHref } from "../lib/settingsLink";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
@@ -55,6 +60,7 @@ import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
@@ -143,19 +149,23 @@ const MOVED: Record<string, string> = {
// AI Proxy is not here: every user keeps personal profiles (S3).
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
/** An unknown section: say so, then show Appearance (`to`). */
function UnknownSection({ name, to }: { name: string; to: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to="/settings/appearance" replace />;
return <Navigate to={to} replace />;
}
export function SettingsHub() {
export function SettingsHub({ section }: { section?: string } = {}) {
const { t } = useTranslation("settings");
const location = useLocation();
const navigate = useNavigate();
const embedded = section !== undefined;
const href = useSettingsHref();
const nav = embedded ? { replace: true } : undefined;
const rootRef = useRef<HTMLDivElement>(null);
const aiTools = useAiTools();
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
@@ -168,6 +178,8 @@ export function SettingsHub() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") return;
// A workspace's Settings tab hidden behind a session: not on screen.
if (rootRef.current?.closest(".hidden")) return;
// A config editor's own find keeps the shortcut.
const el = e.target;
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
@@ -257,7 +269,12 @@ export function SettingsHub() {
group: "agents",
Icon: Bot,
...s("agents"),
render: () => <AiToolsSettings settingsHref={toolHref} />,
render: () => (
<div className="space-y-6">
<AgentInstructionsSettings />
<AiToolsSettings settingsHref={toolHref} />
</div>
),
},
...(enabled("claude")
? [
@@ -381,7 +398,7 @@ export function SettingsHub() {
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
const page = toolTabs.find((x) => x.tool === id);
return page ? `/settings/${page.id}` : undefined;
return page ? href(page.id) : undefined;
}
const entries: SettingsEntry[] = sections.flatMap((sec) => [
@@ -402,7 +419,9 @@ export function SettingsHub() {
})),
]);
const rest = location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
const rest = embedded
? section
: location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
if (!rest) {
const params = new URLSearchParams(location.search);
const tool = params.get("tool");
@@ -412,9 +431,9 @@ export function SettingsHub() {
: tool
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
: "appearance";
return <Navigate to={`/settings/${to}`} replace />;
return <Navigate to={href(to)} replace />;
}
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
if (MOVED[rest]) return <Navigate to={href(MOVED[rest])} replace />;
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
@@ -426,7 +445,7 @@ export function SettingsHub() {
<Skeleton className="h-40 w-full" />
</div>
);
return <UnknownSection name={rest} />;
return <UnknownSection name={rest} to={href("appearance")} />;
}
const ranked = query.trim() ? rankSettings(query, entries) : null;
@@ -437,8 +456,8 @@ export function SettingsHub() {
const navActive = navId(active.id);
const results = ranked ? rankSettingsEntries(query, entries) : [];
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
const go = (id: string) => navigate(`/settings/${id}`);
const hrefOf = (e: SettingsEntry) => href(`${e.section}${e.to ?? ""}`);
const go = (id: string) => navigate(href(id), nav);
const back = (e: MouseEvent) => {
if (!canGoBack) return;
e.preventDefault();
@@ -454,7 +473,7 @@ export function SettingsHub() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]), nav);
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
@@ -472,6 +491,7 @@ export function SettingsHub() {
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
replace={embedded}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
>
@@ -486,15 +506,17 @@ export function SettingsHub() {
);
return (
<div>
<Link
to="/workspace"
onClick={back}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
<div ref={rootRef}>
{!embedded && (
<Link
to="/workspace"
onClick={back}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
)}
<div className="flex gap-8">
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
{searchBox}
@@ -516,7 +538,8 @@ export function SettingsHub() {
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
to={href(id)}
replace={embedded}
aria-current={id === navActive ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
+97 -36
View File
@@ -21,6 +21,10 @@
* closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Its bottom fold, Workspace context,
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
* Settings opened on a workspace (`?settings=<section>`, `lib/settingsLink.ts`)
* is a tab of its own at the end of the tab bar, kept until its X: the
* Settings pages (`SettingsHub`) shown instead of the panes, which stay
* mounted behind them.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -116,6 +120,8 @@ import {
updateActive,
} from "../lib/paneLayout";
import { readAllTabs } from "../lib/workspaceTabs";
import { settingsHref } from "../lib/settingsLink";
import { SettingsHub } from "./SettingsHub";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
@@ -204,6 +210,7 @@ function ConsoleArea({
onOpenRun,
onRunsChanged,
allTabs = false,
settings = null,
}: {
lanes: Lane[];
selectedLaneId: number | null;
@@ -226,6 +233,14 @@ function ConsoleArea({
onRunsChanged: () => void;
/** Tabs from every workspace, not only the selected one's. */
allTabs?: boolean;
/** The Settings tab while it is open: its section, and whether it is the one
* on screen (the panes are then hidden, never unmounted). */
settings?: {
section: string;
active: boolean;
onOpen: () => void;
onClose: () => void;
} | null;
}) {
const { t } = useTranslation("lanes");
const aiTools = useAiTools();
@@ -473,7 +488,8 @@ function ConsoleArea({
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
const areaRef = useRef<HTMLDivElement>(null);
onKeys.current = (e) => {
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!attachRequest || settings?.active || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
return;
// Not under a dialog, and not while the console is hidden (the Changes
// panel at full width, a replay, the home) — CSS-hidden, so by class.
if (document.querySelector('[role="dialog"]')) return;
@@ -520,9 +536,10 @@ function ConsoleArea({
onRenamed={() => setSessionsVersion((v) => v + 1)}
onClosed={(runId) => ended(keyOf(runId))}
onHome={onNewSession}
settings={settings}
// While one pane is shown, its split menus (a split pane has its own).
actions={
shown.length === 1 && shown[0] !== undefined ? (
!settings?.active && shown.length === 1 && shown[0] !== undefined ? (
<div className="flex items-center gap-1">
<SplitMenus
slot={shown[0]}
@@ -540,38 +557,48 @@ function ConsoleArea({
}
/>
)}
<PaneGrid
layout={view}
mounted={mounted}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
// With `allTabs` a pane is bound to its own run's workspace.
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
slot={slotOfKey(slot)}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
panelOpen={spanning.has(slot)}
attachRequest={
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
)}
/>
{settings && (
<div
data-testid="workspace-settings"
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
>
<SettingsHub section={settings.section} />
</div>
)}
<div className={settings?.active ? "hidden" : "contents"}>
<PaneGrid
layout={view}
mounted={mounted}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
// With `allTabs` a pane is bound to its own run's workspace.
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
slot={slotOfKey(slot)}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
panelOpen={spanning.has(slot)}
attachRequest={
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
)}
/>
</div>
</div>
);
}
@@ -592,6 +619,14 @@ export function Workspace() {
// `?session=` — an ENDED session picked in the sidebar or Recents: its
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
// `?settings=<section>` — the Settings tab is on screen ("" = the section it
// was last on). It stays open (`settingsTab`, its last section) until its X.
const settingsParam = searchParams.get("settings");
const [settingsTab, setSettingsTab] = useState<string | null>(null);
const settingsSection = settingsParam || settingsTab || "appearance";
useEffect(() => {
if (settingsParam !== null) setSettingsTab(settingsSection);
}, [settingsParam, settingsSection]);
const navigate = useNavigate();
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
// it rides on the navigation's state, never in the URL.
@@ -1058,8 +1093,9 @@ export function Workspace() {
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam && !sessionParam;
const replaying = !!currentLane && !!sessionParam;
const settingsOn = !!currentLane && settingsParam !== null;
const home = !!currentLane && !runParam && !sessionParam && !settingsOn;
const replaying = !!currentLane && !!sessionParam && !settingsOn;
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
// or the one the opened pane holds.
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
@@ -1341,6 +1377,31 @@ export function Workspace() {
)
}
onRunsChanged={refreshList}
settings={
currentLane && (settingsOn || settingsTab !== null)
? {
section: settingsSection,
active: settingsOn,
onOpen: () =>
navigate(settingsHref(settingsTab ?? "", location), {
replace: true,
}),
onClose: () => {
setSettingsTab(null);
if (!settingsOn) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("settings");
next.delete("tab");
return next;
},
{ replace: true }
);
},
}
: null
}
/>
</div>
</div>
@@ -49,6 +49,9 @@ vi.mock("../../components/settings/ReportSettings", () => ({
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
}));
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
}));
@@ -330,3 +333,36 @@ describe("SettingsHub", () => {
off();
});
});
describe("SettingsHub as a workspace's Settings tab", () => {
it("shows the section it is given, no Back link, and keeps every link in the tab", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=3&run=agenthub-lane-3"]}>
<Routes>
<Route
path="/workspace"
element={
<>
<SettingsHub section="appearance" />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.queryByText(/^Back/)).toBeNull();
const notifications = within(nav()).getByRole("link", { name: "Notifications" });
expect(notifications).toHaveAttribute(
"href",
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
);
fireEvent.click(notifications);
expect(screen.getByTestId("where")).toHaveTextContent(
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
);
});
});
@@ -101,6 +101,11 @@ vi.mock("../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true),
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../SettingsHub", () => ({
SettingsHub: ({ section }: { section?: string }) => (
<div data-testid="settings-hub">{section}</div>
),
}));
vi.mock("../../components/lanes/SessionReplay", () => ({
SessionReplay: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-replay">{sessionId}</div>
@@ -629,6 +634,28 @@ describe("console tabs", () => {
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
});
it("opens Settings as a tab of its own, the console only hidden, kept until its X", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1&settings=agents");
const user = userEvent.setup();
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// A session tab shows its panes again; the Settings tab stays, hidden.
await user.click(screen.getByTestId("console-tab-0"));
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
expect(screen.getByTestId("workspace-settings")).toHaveClass("hidden");
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "false");
// Back on it: the section it was on.
await user.click(screen.getByTestId("console-tab-settings"));
expect(screen.getByTestId("workspace-settings")).not.toHaveClass("hidden");
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
await user.click(screen.getByTestId("console-tab-settings-close"));
expect(screen.queryByTestId("console-tab-settings")).toBeNull();
expect(screen.queryByTestId("settings-hub")).toBeNull();
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
});
it("opens a new terminal in the first free slot from the + menu", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
@@ -5580,30 +5580,34 @@ exports[`screen snapshots > Run 1`] = `
class="flex min-h-0 flex-1 flex-col"
>
<div
class="relative min-h-0 flex-1"
data-testid="pane-grid"
class="contents"
>
<div
class="absolute flex flex-col"
data-focused="true"
data-menu-bounds="true"
data-testid="pane-0"
style="left: 0%; top: 0%; width: 100%; height: 100%;"
class="relative min-h-0 flex-1"
data-testid="pane-grid"
>
<div
class="flex min-h-0 flex-1 flex-col"
class="absolute flex flex-col"
data-focused="true"
data-menu-bounds="true"
data-testid="pane-0"
style="left: 0%; top: 0%; width: 100%; height: 100%;"
>
<div
class="flex min-h-0 flex-1 flex-col gap-3"
data-testid="console-body"
class="flex min-h-0 flex-1 flex-col"
>
<div
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
class="flex min-h-0 flex-1 flex-col gap-3"
data-testid="console-body"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
<div
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
</div>
</div>
</div>
@@ -7174,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.0 (rebuild client to match)
UI build v2.7.4 (rebuild client to match)
</span>
</p>
</div>
+14 -8
View File
@@ -97,6 +97,8 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
| --- | --- |
| `GET /api/me/prefs` | `{prefs: {<key>: "<value>"}, onboardedAt}`; `401 EAUTH` with no login |
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
@@ -227,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
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
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
back on ANY of those to the in-app folder browser
`GET /api/lanes/browse?path=<abs>`; a file field falls back to typing the path
on `EREMOTE`/`ENODIALOG` and shows the error otherwise. In server mode with
back on ANY of those to the in-app browser `GET /api/lanes/browse?path=<abs>`.
A file field asks it with `files=1`: entries then include the folder's files
(`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
(`parent: null` there) and any path whose real path leaves it is `403 EFOLDER`.
@@ -355,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 `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.
#### Preflight a destructive workspace action
@@ -1386,7 +1392,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
#### Projects (workspaces grouped by repository)
@@ -1597,7 +1603,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
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
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
@@ -2598,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
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-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
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
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 — 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)
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)
+9 -7
View File
File diff suppressed because one or more lines are too long
+40 -3
View File
@@ -797,8 +797,45 @@ agenthub users provision alice # the Linux account only (a user made before
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
recorded ones).
- Keep every other AgentHub server off the same database (a dev server on
another port): an older build cannot see members' tmux servers and marks
their runs abandoned.
another port). A server without S3 cannot see members' tmux servers, so 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
runs interrupted or abandoned within seconds, and the proxy gateway then
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
@@ -847,7 +884,7 @@ To remove everything:
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
| `403 EFOREIGN` on a file view or save | The workspace's folder is now a link into another account's files. Put the real folder back, or make a workspace on the real one. |
| A member's runs show as abandoned | Another, older AgentHub server on the same database (see Upgrade). |
| A member's runs show as abandoned or interrupted, or Claude in a member's pane gets `401 not a live pane's token` | Another AgentHub server older than v2.7.2 on the same database (see Upgrade). |
| `sudo: a password is required` in the server log | The sudoers file is missing, or a tool moved off its recorded path: re-run `install-system`. |
| `[drop-caps] setpriv is missing` | Install util-linux's `setpriv` at `/usr/bin/setpriv`; until then the dashboard's children inherit `CAP_DAC_READ_SEARCH`. |
| `[plugin-baseline] <name>: claude is not in the shared tools yet` | `claude` is not published to `/opt/agenthub/tools` yet: `agenthub server tools`, then `agenthub users provision <name>`. |
+88 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.7.0
version: 2.7.4
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -13822,6 +13822,93 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/instructions:
get:
tags:
- Auth
summary: The account's own agent instructions
description: Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.
responses:
'200':
description: Instructions
content:
application/json:
schema:
type: object
required:
- instructions
- max
- tools
properties:
instructions:
type: string
max:
type: integer
tools:
type: array
items:
type: string
description: AI tool ids whose new panes receive the instructions (claude, codex)
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
tags:
- Auth
summary: Replace the account's agent instructions (blank or null clears)
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
instructions:
type: string
nullable: true
maxLength: 16000
responses:
'200':
description: Saved
content:
application/json:
schema:
type: object
required:
- instructions
- max
- tools
properties:
instructions:
type: string
max:
type: integer
tools:
type: array
items:
type: string
description: AI tool ids whose new panes receive the instructions (claude, codex)
'400':
description: EBADFIELD — not a string
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'413':
description: ETOOBIG — longer than 16 000 characters
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/onboarded:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.7.0",
"version": "2.7.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.7.0",
"version": "2.7.4",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.7.0",
"version": "2.7.4",
"bin": {
"agenthub": "bin/agenthub.js"
},
+10 -2
View File
@@ -16,7 +16,9 @@
* setting never writes the developer's real `~/.config/agenthub/settings.env`,
* 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
* 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>
*/
@@ -34,8 +36,14 @@ const files = fs
const args = process.argv.slice(2);
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 = {
...process.env,
...inherited,
TMUX_TMPDIR: tmuxDir,
// Never the developer's real ~/.config/agenthub/settings.env: it can pin
// 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) |
| `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 |
| `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 |
| `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 |
+18 -1
View File
@@ -22,8 +22,17 @@ before(() => {
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
// Long enough ago that no reconcile treats the run as just started.
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", () => {
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");
});
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", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
+28
View File
@@ -143,6 +143,34 @@ describe("collectChanges — work range", () => {
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 () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
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.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", () => {
+11
View File
@@ -238,6 +238,17 @@ describe("lane-sources instructions and launch", () => {
src.setInstructions(codexLane.id, null);
});
it("puts the owner's own agent instructions before the workspace's", () => {
src.setUserInstructions("local", "# my style\n- small diffs");
const { systemPrompt, systemPromptFile } = src.launchExtras(lane.id, "claude");
assert.match(systemPrompt, /^# my style\n- small diffs\n\n- always answer in Vietnamese/);
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
// A workspace with nothing of its own still gets them.
assert.match(src.launchExtras(otherLane.id, "claude").systemPrompt, /^# my style/);
src.setUserInstructions("local", null);
assert.equal(src.launchExtras(otherLane.id, "claude").systemPrompt, null);
});
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
addDirs: [],
+10
View File
@@ -427,6 +427,16 @@ describe("lanes changed by another server on the same DB", () => {
});
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 () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
+29
View File
@@ -74,6 +74,22 @@ describe("local mode: one account", () => {
}
});
it("keeps the account's own agent instructions, capped, blank clears", async () => {
let r = await call("GET", "/api/me/instructions");
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
r = await call("PUT", "/api/me/instructions", {
body: { instructions: "- answer in Vietnamese\r\n" },
});
assert.equal(r.status, 200);
assert.equal(r.body.instructions, "- answer in Vietnamese\n");
r = await call("PUT", "/api/me/instructions", { body: { instructions: 1 } });
assert.equal(r.body.error.code, "EBADFIELD");
r = await call("PUT", "/api/me/instructions", { body: { instructions: "x".repeat(16001) } });
assert.equal(r.status, 413);
r = await call("PUT", "/api/me/instructions", { body: { instructions: " " } });
assert.equal(r.body.instructions, "");
});
it("marks the first-run setup done, once", async () => {
const r = await call("POST", "/api/me/onboarded");
assert.match(r.body.onboardedAt, /^\d{4}-/);
@@ -117,6 +133,19 @@ describe("server mode: per account", () => {
assert.deepEqual(m, { prefs: { i18nextLng: "en" }, onboardedAt: null });
});
it("keeps each account's own agent instructions", async () => {
assert.equal((await call("GET", "/api/me/instructions")).status, 401);
await call("PUT", "/api/me/instructions", { cookie: mia, body: { instructions: "mia's" } });
assert.equal(
(await call("GET", "/api/me/instructions", { cookie: mia })).body.instructions,
"mia's"
);
assert.equal(
(await call("GET", "/api/me/instructions", { cookie: max })).body.instructions,
""
);
});
it("puts an S3 member's mode into their Claude Code theme, as them", async () => {
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
+7
View File
@@ -44,6 +44,13 @@ describe("native-dialog", () => {
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", () => {
for (const kind of ["dir", "file"]) {
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;
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"));
// ...as an OPTION only: the starting mode stays the requested one, and
// `--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 () => {
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", {
method: "POST",
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 [
path.basename(pdf.body.path), // a drop, but no image
"0123456789abcdef.png", // the right shape, never dropped
@@ -0,0 +1,68 @@
/**
* @file s3-unseen-member-runs.test.js
* @description A dashboard WITHOUT S3 (local mode, e.g. a dev server) sharing
* the database of an S3 server must never end a member's run or release a
* member's workspace: their panes live on the member's own tmux server, which
* it cannot ask. Before the fix its heal beat marked every member run
* `interrupted` within 10 s, and the proxy gateway then refused the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "s3-unseen-"));
const USERS = path.join(ROOT, "users");
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.AGENTHUB_SERVER_CONF = path.join(ROOT, "server.conf");
delete process.env.AGENTHUB_MODE; // local mode: this server has no S3
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=host\nHOST_UID=1001\nUSERS_ROOT=${USERS}\n`
);
const runs = require("../lib/dashboard-runs");
const { db } = require("../db");
const { healAllRunIds } = require("../routes/lanes");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const start = (id, laneId, cwd) =>
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", () => {
it("never ends a member's run, and still ends its own", () => {
start("agenthub-lane-1", 1, path.join(USERS, "mia", "proj"));
start("agenthub-lane-2", 2, "/home/host/proj");
runs.reconcileOrphans(() => false);
assert.equal(runs.getRun("agenthub-lane-1").status, "running");
assert.equal(runs.getRun("agenthub-lane-2").status, "interrupted");
});
it("never releases a member's workspace", () => {
const lane = db.prepare(
"INSERT INTO lanes (title, cwd, run_id, status) VALUES (?, ?, ?, 'running')"
);
const member = lane.run(
"m",
path.join(USERS, "mia", "proj"),
"agenthub-lane-1"
).lastInsertRowid;
const host = lane.run("h", "/home/host/proj", "agenthub-lane-2").lastInsertRowid;
healAllRunIds(new Set());
const runId = (id) => db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(id).run_id;
assert.equal(runId(member), "agenthub-lane-1");
assert.equal(runId(host), null);
});
});
+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"));
});
});
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"
);
});
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
* 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
* 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
* account — and a fake `sudo` on PATH that never returns stands in for a
* stalled member, in a child process.
@@ -674,7 +675,54 @@ describe("a dropped file", () => {
const host = await drop("boss", hostLane);
assert.equal(host.status, 200, JSON.stringify(host.body));
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.
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()));
}
});
});
+7
View File
@@ -1778,6 +1778,13 @@ db.exec(`
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// An account's own agent instructions, put before every workspace's in each
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
try {
db.prepare("SELECT instructions FROM user_prefs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE user_prefs ADD COLUMN instructions TEXT").run();
}
// The `member` role was renamed `developer`: rebuild `users` for the new
// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
+17 -2
View File
@@ -258,8 +258,21 @@ function claimToolSessions(sessionId = null) {
}
const runningStmt = db.prepare(
"SELECT id, tool, session_id 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(`
UPDATE dashboard_runs
SET status = @status,
@@ -298,8 +311,9 @@ const pruneShellsStmt = db.prepare(`
function reconcileOrphans(isAlive = () => false) {
try {
let changes = 0;
const { unseenMemberFolder } = require("./linux-accounts");
for (const row of runningStmt.all()) {
if (isAlive(row.id)) continue;
if (isAlive(row.id) || unseenMemberFolder(row.cwd) || isYoungRun(row.started_at)) continue;
const interrupted =
row.tool !== "shell" &&
!!row.session_id &&
@@ -367,6 +381,7 @@ module.exports = {
getRun,
forgetShellRun,
reconcileOrphans,
isYoungRun,
listInterrupted,
dismissInterrupted,
proxyForSession,
+6 -2
View File
@@ -395,13 +395,17 @@ async function summarizeWork(dir) {
const [entries, numstat] = await Promise.all([
laneStatus(dir),
// --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 = [];
for (const { code, path: file } of entries) {
const untracked = code === "??";
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
// binary-ness are the same buffer answered twice.
// Sequential on purpose: `normal` folds untracked dirs, so this is short.
+38 -4
View File
@@ -9,7 +9,9 @@
* name it chooses, so a request never names a path on disk. Only Claude
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
* `-c developer_instructions=`) can take them; every other tool is reported
* unsupported and its panes get nothing.
* unsupported and its panes get nothing. The workspace OWNER's own agent
* instructions (`user_prefs.instructions`, Settings → My agent instructions)
* go first, before the workspace's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
@@ -185,8 +187,8 @@ function removeLaneContext(laneId) {
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
}
function setInstructions(laneId, text) {
laneRow(laneId);
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
function cleanInstructions(text) {
if (text !== null && typeof text !== "string") {
throw fail("EBADFIELD", "instructions must be a string or null");
}
@@ -194,9 +196,37 @@ function setInstructions(laneId, text) {
if (value && value.length > MAX_INSTRUCTIONS) {
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
}
return value;
}
function setInstructions(laneId, text) {
laneRow(laneId);
const value = cleanInstructions(text);
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
}
function setUserInstructions(userKey, text) {
const value = cleanInstructions(text);
db.prepare(
`INSERT INTO user_prefs (user_key, instructions) VALUES (?, ?)
ON CONFLICT(user_key) DO UPDATE SET instructions = excluded.instructions,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
).run(userKey, value);
}
function getUserInstructions(userKey) {
return (
db.prepare("SELECT instructions FROM user_prefs WHERE user_key = ?").get(userKey)
?.instructions || null
);
}
/** The owner's own instructions: `local` in local mode, `u<owner_id>` in server mode. */
function ownerInstructions(ownerId) {
if (!require("./auth").isServerMode()) return getUserInstructions("local");
return ownerId ? getUserInstructions(`u${ownerId}`) : null;
}
/**
* What a pane of this workspace is started with, read from its rows: the
* extra folders and (when any file was uploaded) the context dir to allow, and
@@ -207,7 +237,7 @@ function setInstructions(laneId, text) {
function launchExtras(laneId, tool) {
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
if (!supports(tool)) return empty;
const lane = db.prepare("SELECT instructions FROM lanes WHERE id = ?").get(laneId);
const lane = db.prepare("SELECT instructions, owner_id FROM lanes WHERE id = ?").get(laneId);
if (!lane) return empty;
const rows = db
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
@@ -238,6 +268,8 @@ function launchExtras(laneId, tool) {
}
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
const parts = [];
const mine = ownerInstructions(lane.owner_id);
if (mine) parts.push(mine);
if (lane.instructions) parts.push(lane.instructions);
if (files.length) {
parts.push(
@@ -366,6 +398,8 @@ module.exports = {
removeLaneContext,
setInstructions,
launchExtras,
setUserInstructions,
getUserInstructions,
codexInstructions,
tomlString,
INLINE_PROMPT_BYTES,
+15
View File
@@ -70,6 +70,20 @@ function s3Enabled() {
return require("./auth").isServerMode() && serverConf() !== null;
}
/**
* A member's folder (under this machine's S3 users root) while THIS server
* runs without S3 — a local-mode or dev dashboard on the same database. Its
* panes live on the member's own tmux server, which such a server cannot
* ask, so it must never judge them dead.
*/
function unseenMemberFolder(cwd) {
if (typeof cwd !== "string" || !cwd || s3Enabled()) return false;
const conf = serverConf();
if (!conf) return false;
const rel = path.relative(conf.usersRoot, path.resolve(cwd));
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
}
const loginFor = (username) => `ah-${username}`;
/** The Linux side of an AgentHub user, or null outside S3. */
@@ -129,6 +143,7 @@ module.exports = {
LINUX_NAME_RE,
serverConf,
s3Enabled,
unseenMemberFolder,
accountFor,
assertLinuxName,
provision,
+4
View File
@@ -85,6 +85,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
const title = TITLES[kind];
if (platform === "darwin") {
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 [
[
"osascript",
@@ -92,6 +94,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
"-e",
"on run argv",
"-e",
"activate",
"-e",
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
"-e",
"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)];
}
/**
* 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({
model,
permissionMode,
@@ -177,6 +195,7 @@ function buildClaudeArgv({
addDirs = [],
systemPromptFile = null,
proxyArgv = [],
offerBypass = true,
platform = process.platform,
}) {
const argv = ["claude"];
@@ -210,7 +229,11 @@ function buildClaudeArgv({
// 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.
// 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 (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
if (resumeSessionId) argv.push("--resume", resumeSessionId);
@@ -512,6 +535,11 @@ function spawnRun(args) {
resumeSessionId,
forkSession: forkSession === true,
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,
resumeLast,
bypass: bypass === true,
+45 -12
View File
@@ -133,13 +133,22 @@ function memberHomes() {
function membersWithTmux() {
return memberHomes()
.map((home) => accountForPath(home))
.filter((acct) => {
try {
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch {
return false;
}
});
.filter(mayHaveTmux);
}
/**
* 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).
@@ -357,8 +366,7 @@ function openSafe(file) {
// 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.
const MEMBER_READ_MAX = 64 * 1024 * 1024;
const tooBig = (file) =>
Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
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);
const buf = Buffer.alloc(size);
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);
} finally {
fs.closeSync(fd);
@@ -398,7 +406,7 @@ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
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;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
@@ -438,7 +446,10 @@ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
let start = 0;
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
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 = [];
partLen = 0;
skipping = false;
@@ -477,6 +488,26 @@ function sameOwner(...paths) {
/** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */
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");
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
@@ -502,6 +533,7 @@ module.exports = {
assertOwnerFits,
hasLinuxAccount,
membersWithTmux,
mayHaveTmux,
memberHomes,
ownerForRead,
tmuxSocket,
@@ -516,6 +548,7 @@ module.exports = {
handoffWrite,
writeAs,
mkdirAs,
makeMemberFolder,
removeAs,
launchArgv,
sameOwner,
+63 -4
View File
@@ -7,26 +7,85 @@
* 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
* 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>
*/
const fs = require("fs");
const path = require("path");
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.
* @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 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,
detached: true,
stdio: "ignore",
env: process.env,
});
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,
// 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) —
// 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`).
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
function memberListOutput(acct) {
const hit = listed.get(acct.login);
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) {
throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" });
}
+48
View File
@@ -7,6 +7,7 @@
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -90,6 +91,23 @@ const changeResponses = {
const prefsBody = {
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
};
const instrBody = {
"application/json": {
schema: {
type: "object",
required: ["instructions", "max", "tools"],
properties: {
instructions: { type: "string" },
max: { type: "integer" },
tools: {
type: "array",
items: { type: "string" },
description: "AI tool ids whose new panes receive the instructions (claude, codex)",
},
},
},
},
};
const paths = {
"/api/me/prefs": {
@@ -127,6 +145,36 @@ const paths = {
},
},
},
"/api/me/instructions": {
get: {
tags: ["Auth"],
summary: "The account's own agent instructions",
description:
"Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.",
responses: { 200: { description: "Instructions", content: instrBody }, 401: err("EAUTH") },
},
put: {
tags: ["Auth"],
summary: "Replace the account's agent instructions (blank or null clears)",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { instructions: { type: "string", nullable: true, maxLength: 16000 } },
},
},
},
},
responses: {
200: { description: "Saved", content: instrBody },
400: err("EBADFIELD — not a string"),
413: err("ETOOBIG — longer than 16 000 characters"),
401: err("EAUTH"),
},
},
},
"/api/me/onboarded": {
post: {
tags: ["Auth"],
+36 -4
View File
@@ -35,7 +35,7 @@ const { sameOriginGuard } = require("./run");
const { isLocalRequest } = require("../lib/same-origin");
const scope = require("../lib/scope");
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 {
LANES_ROOT,
@@ -123,8 +123,15 @@ function lastEventAge(lane) {
*/
function healRunId(lane, live = null) {
if (!lane.run_id) return lane;
// A member's pane on a server without S3 (a dev dashboard on the same DB):
// its tmux is not ours to ask, so "not listed" proves nothing.
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
if (live) {
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 {
const run = runs.getRun(lane.run_id);
if (run && run.status === "running") return lane;
@@ -557,15 +564,20 @@ router.post("/gc", sameOriginGuard, (req, res) => {
* expose absolute filesystem paths from a native picker, so path selection is
* done by browsing server-side instead. Read-only; registered ahead of
* "/: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) => {
// 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
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
const own = scope.ownFolder(req);
const files = req.query.files === "1";
const raw =
typeof req.query.path === "string" && req.query.path.trim()
? req.query.path
? files
? startDir(req.query.path)
: req.query.path
: own || os.homedir();
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
@@ -586,12 +598,14 @@ router.get("/browse", (req, res) => {
try {
entries = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.filter((e) => (e.isDirectory() || (files && e.isFile())) && !e.name.startsWith("."))
.map((e) => {
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")) };
})
.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 {
// An unreadable entry mid-listing is skipped, not a request failure.
}
@@ -2057,6 +2071,24 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try {
switch (action) {
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
// client's own pick comes from a list that may already be stale.
const auto = body.slot === "auto";
+30
View File
@@ -8,6 +8,10 @@
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
* string of at most 8 KB, and a PUT merges into the row (`null` removes a
* key). Writes are same-origin and touch only the caller's own row.
* `/instructions` is the account's own agent instructions (issue #67, at most
* 16 000 characters, blank clears), put before the workspace's in every pane
* of a workspace the account owns (`launchExtras` in lib/lane-sources.js);
* `tools` names the AI tools whose panes receive them.
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
*
@@ -105,6 +109,32 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
res.json(read(key));
});
const sources = require("../lib/lane-sources");
const instructionsView = (key) => ({
instructions: sources.getUserInstructions(key) || "",
max: sources.MAX_INSTRUCTIONS,
// The tools a pane of which actually receives them (see launchExtras).
tools: [...sources.SUPPORTED_TOOLS],
});
router.get("/instructions", (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
res.json(instructionsView(key));
});
router.put("/instructions", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
try {
sources.setUserInstructions(key, req.body?.instructions ?? null);
} catch (err) {
const status = err.code === "ETOOBIG" ? 413 : 400; // as PUT /api/lanes/:id/instructions
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
res.json(instructionsView(key));
});
router.post("/onboarded", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
+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
// 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
// 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;
try {
if (type) {
const { dir, member } = dropDir(req);
for (const { dir, member } of dirs) {
try {
fd = fs.openSync(
path.join(dir, name),
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"
? process.getuid()
: st.uid;
if (!st.isFile() || st.uid !== uid) {
fs.closeSync(fd);
fd = null;
}
if (st.isFile() && st.uid === uid) break;
fs.closeSync(fd);
fd = null;
} catch {
/* pruned, never dropped, or a link */
}
} catch {
/* pruned, never dropped, or a link */
}
if (fd === null) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such image" } });
+16 -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
// 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
// (another machine's paths), and not in server mode, where a member's hook
// could name a host-only file that this process would then read as the host.
// (another machine's paths). In server mode only with S3, where a member's hook
// 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 = {
png: "image/png",
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 session = stmts.getSession.get(req.params.id);
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");
}
const file = typeof req.query.path === "string" ? req.query.path : "";
const type = READ_IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
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)) {
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.
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
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");
const buf = await fh.readFile();
res.set({
+9 -4
View File
@@ -35,7 +35,7 @@ const {
importExportBundle,
exportableRun,
} = require("../lib/data-transfer");
const { sessionWhere, bySessionWhere } = require("../lib/scope");
const { sessionWhere, bySessionWhere, memberId } = require("../lib/scope");
function getDbSize() {
try {
@@ -127,15 +127,20 @@ router.get("/info", (req, res) => {
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({
db: {
path: DB_PATH,
path: developer ? null : DB_PATH,
size: dbSize,
counts,
pragmas,
load_stats,
},
hooks: hookStatus,
hooks: developer ? { installed: hookStatus.installed } : hookStatus,
server: {
version: APP_VERSION,
uptime: process.uptime(),
@@ -152,7 +157,7 @@ router.get("/info", (req, res) => {
free_mem: os.freemem(),
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();
router.get("/status", async (_req, res) => {
router.get("/status", async (req, res) => {
try {
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);
} catch (err) {
res.status(500).json({