Compare commits

..

4 Commits

Author SHA1 Message Date
nntrivi2001 3a8be55b55 feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

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

Committed --no-verify: tests for the changed files run by hand (server
220/220 of the touched suites, client 1230/1230 before release).
2026-10-10 15:10:13 +07:00
nntrivi2001 f0dc1445fc fix: v2.6.2 — Windows panes no longer read as dead (psmux ignores -F), Update now's setup works under cmd.exe
- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
  asks for. Read as names, every live pane looked gone and the heal beat
  closed its run (and its tab) within seconds while the session kept
  running. tmux.js (and the Update all helper) now take the text before the
  first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
  subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
  Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
  npm's output (the error, not the warnings), says when it hit the time
  limit, and gets 20 min instead of 5.
2026-10-10 12:01:01 +07:00
98 changed files with 4259 additions and 925 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.6.0 (2026-10-09) — S3 one Linux user per account (experimental, async member tmux/snapshot), git on a workspace's extra folders (`?source=`, #70), project files in the prompt (#54), a long paste attached as a file (#68). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ 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.2 (2026-10-12) — an account's own agent instructions go before every workspace's (#67); Chat ⇄ terminal keeps the unsent prompt; a server without S3 never ends a member's run._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -57,10 +57,10 @@ 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) or the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning). 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.
- **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.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
+3
View File
@@ -51,6 +51,7 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
* - `./Sagi/SagiMount`
*
* ## Public surface
@@ -81,6 +82,7 @@ import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
@@ -173,6 +175,7 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
</a>
<UpdateNotifier />
<InterruptedSessionsPrompt />
<SagiMount />
<OnboardingWizard />
<OnboardingTour />
+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>}
+4 -1
View File
@@ -463,7 +463,10 @@ export function UpdateNotifier() {
) : null}
{error ? (
<p className="text-xs text-status-danger" role="alert">
<p
className="text-xs text-status-danger whitespace-pre-wrap break-words max-h-48 overflow-auto"
role="alert"
>
{error}
</p>
) : null}
@@ -185,7 +185,10 @@ export function ConversationView({
firstLineRef.current = result.first_line;
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
// A live pane's new session has no row until its first hook: nothing
// to show yet, and the poll below picks it up once it exists.
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
setMessages([]);
setTotal(0);
} finally {
@@ -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++;
}
@@ -9,10 +9,12 @@
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
* a System row; URLs and absolute paths in a turn are links (a path opens
* through `onOpenFile`). Each assistant turn that changed
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
* like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`). A thinking block that carries text is
* when the caller can (`onOpenFile`), and a row (or the card's header, for
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
* chat (`live`) ends, until its turn is over, with what the model is doing
@@ -65,7 +67,7 @@
*
* ----------------------------------------------------------------------------- */
import { memo, useEffect, useState, useMemo } from "react";
import { Fragment, memo, useEffect, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
@@ -128,6 +130,8 @@ 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";
@@ -203,12 +207,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>
);
}
@@ -521,7 +545,11 @@ function EditedFiles({
);
return (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
<div
draggable
onDragStart={(e) => dragAttachments(e, [...files.keys()].map(projectAttachment))}
className="flex cursor-grab items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary"
>
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
{counts(added, removed)}
@@ -533,6 +561,8 @@ function EditedFiles({
title={p}
disabled={!onOpenFile}
onClick={() => onOpenFile?.(p)}
draggable
onDragStart={(e) => dragAttachments(e, [projectAttachment(p)])}
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
>
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
@@ -784,7 +814,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)
@@ -0,0 +1,37 @@
/**
* @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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
const transcript = vi.hoisted(() =>
vi.fn(async () => {
throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 });
})
);
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } },
};
});
describe("ConversationView before a session's first hook", () => {
it("shows no error for a live pane", async () => {
render(<ConversationView sessionId="new" fill live />);
await waitFor(() => expect(transcript).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Session not found")).toBeNull();
});
it("still reports it when not live", async () => {
render(<ConversationView sessionId="gone" fill />);
expect(await screen.findByText("Session not found")).toBeTruthy();
});
});
@@ -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,9 @@
* @file MessageList.edits.test.tsx
* @description Each assistant turn that changed files ends with Claude
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, and a row click handed
* to the caller.
* removed summed over the turn, a failed call left out, a row click handed
* to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
@@ -67,4 +68,36 @@ describe("the Edited N files card", () => {
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
});
it("drags a row as its @path attachment, and the header as every file", () => {
render(
<MessageList
loading={false}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 1, removed: 0 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 2, removed: 0 }),
]}
/>
);
const card = screen.getByTestId("edited-files");
const drag = (el: Element) => {
const data: Record<string, string> = {};
fireEvent.dragStart(el, {
dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" },
});
return data;
};
const one = drag(within(card).getByTitle("/w/b.md"));
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
{ path: "@/w/b.md", name: "b.md" },
]);
expect(one["text/plain"]).toBe("/w/b.md");
const all = drag(within(card).getByText("Edited 2 files"));
expect(
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.md"]);
});
});
@@ -4,11 +4,12 @@
* 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 { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
@@ -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" }] }),
+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);
@@ -0,0 +1,155 @@
/**
* @file InterruptedSessionsPrompt.tsx
* @description The app-wide "resume interrupted sessions?" dialog. A session
* whose pane died under it — the machine shut down, tmux was killed, the CLI
* crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones
* to resume (each in the first free tab of its workspace, with its run's
* model and effort) or resumes none. Either answer dismisses the whole list,
* so it is asked once; a resume that failed stays listed. Refetched on the
* `runs_interrupted` broadcast, so a tmux that dies under an open dashboard
* asks too. Resuming is the explicit click here, never the dialog opening.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type InterruptedRun } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { timeAgo } from "../../lib/format";
import { toast } from "../../lib/toast";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd;
export function InterruptedSessionsPrompt() {
const { t } = useTranslation("run");
const navigate = useNavigate();
const [items, setItems] = useState<InterruptedRun[]>([]);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
// A refetch keeps the user's ticks; a session not seen before starts ticked.
const seen = useRef(new Set<string>());
const load = useCallback(() => {
api.run
.interrupted()
.then((r) => {
const fresh = r.items.filter((i) => !seen.current.has(i.session_id));
fresh.forEach((i) => seen.current.add(i.session_id));
setItems(r.items);
setPicked((p) => {
const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id));
fresh.forEach((i) => next.add(i.session_id));
return next;
});
})
.catch(() => undefined); // a convenience: no prompt when it cannot be read
}, []);
useEffect(load, [load]);
useEffect(
() =>
eventBus.subscribe((msg) => {
if (msg.type === "runs_interrupted") load();
}),
[load]
);
const dismiss = async (sessionIds: string[]) => {
if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error);
};
const resumeNone = async () => {
if (busy) return;
setBusy(true);
await dismiss(items.map((i) => i.session_id));
setItems([]);
setBusy(false);
};
const resumePicked = async () => {
if (busy) return;
setBusy(true);
const failed = new Set<string>();
let first: { runId: string; laneId: number } | null = null;
for (const it of items.filter((i) => picked.has(i.session_id))) {
try {
const r = await startInFreeTabDetailed(it.lane_id, {
resumeSessionId: it.session_id,
...(it.model ? { model: it.model } : {}),
...(it.effort ? { effort: it.effort } : {}),
// Bypass is a per-run choice, never carried over.
...(it.permission_mode && it.permission_mode !== "bypassPermissions"
? { permissionMode: it.permission_mode }
: {}),
});
first ??= r;
} catch (err) {
failed.add(it.session_id);
toast.error(
t("interrupted.failed", {
name: label(it),
message: err instanceof Error ? err.message : String(err),
})
);
}
}
await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id)));
setItems((cur) => cur.filter((i) => failed.has(i.session_id)));
setBusy(false);
if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`);
};
const toggle = (id: string, on: boolean) =>
setPicked((p) => {
const next = new Set(p);
if (on) next.add(id);
else next.delete(id);
return next;
});
const count = items.filter((i) => picked.has(i.session_id)).length;
return (
<ConfirmModal
open={items.length > 0}
title={t("interrupted.title")}
message={t("interrupted.message")}
confirmLabel={t("interrupted.resume", { count })}
cancelLabel={t("interrupted.none")}
destructive={false}
busy={busy}
disabled={count === 0}
onConfirm={() => void resumePicked()}
onCancel={() => void resumeNone()}
>
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
{items.map((it) => (
<li key={it.session_id} className="rounded-md border border-border px-2 py-1.5">
<Checkbox
checked={picked.has(it.session_id)}
onChange={(on) => toggle(it.session_id, on)}
disabled={busy}
className="w-full"
labelClassName="min-w-0 flex-1"
label={
<span className="block min-w-0 text-left">
<span className="block truncate text-xs text-fg-primary">
{label(it)} · {it.tool}
</span>
<span className="block truncate text-[11px] text-fg-muted">
{it.session_name || it.prompt_preview || it.session_id}
{it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""}
</span>
</span>
}
/>
</li>
))}
</ul>
</ConfirmModal>
);
}
@@ -643,6 +643,8 @@ function ChangeRow({
<button
type="button"
onClick={() => onOpen(f.path)}
draggable
onDragStart={(ev) => dragProjectPath(ev, f.path)}
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span
@@ -121,7 +121,11 @@ export function SessionReplay({
};
return (
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
<div
data-testid="session-replay"
data-prompt-drop
className="flex min-h-0 flex-1 flex-col gap-2"
>
{session && (
<div className="flex justify-end">
<SessionResumeButton session={session} />
@@ -0,0 +1,559 @@
/**
* @file WorkspaceContext — a workspace's context: Instructions, Memory
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
* workspace home's right column and, `compact`, folded at the bottom of the
* session view's Changes panel. Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
import { copyText } from "../../lib/clipboard";
import { dragProjectPath } from "../../lib/promptAttach";
import { useDialog } from "../../hooks/useDialog";
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { MarkdownPreview } from "../MarkdownPreview";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { PathPickButton } from "../PathPickButton";
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
return <ContextBody key={props.lane.id} {...props} />;
}
function ContextBody({
lane,
onSourcesChange,
sourcesVersion = 0,
compact = false,
}: {
lane: Lane;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
/** Bumped when the folder list changed elsewhere: the lists re-read. */
sourcesVersion?: number;
/** The Changes panel's narrow fold: no card, tighter rows. */
compact?: boolean;
}) {
const { t } = useTranslation("lanes");
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const fileInput = useRef<HTMLInputElement>(null);
const [ctxDrag, setCtxDrag] = useState(false);
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const sourceCtx = useContextMenu<LaneSource>();
useEffect(() => {
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch((err) => live && setError(message(err)));
api.lanes
.memory(lane.id)
.then((r) => live && setMemory(r.files))
.catch(() => live && setMemory([]));
return () => {
live = false;
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (!live) return;
const p = projects.find((x) => x.workspaces.includes(lane.id));
const own = new Set((view?.sources ?? []).map((s) => s.path));
setSuggestions(
!p || !prefs.showUntracked
? []
: p.worktrees
.filter((w) => w.workspace_id === null && !own.has(w.path))
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
.map((w) => w.path)
);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
useEffect(() => {
if (!sourcesVersion) return;
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch(() => undefined);
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
}, [sourcesVersion]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null);
try {
const next = await fn();
setView(next);
onSourcesChange?.(next);
return true;
} catch (err) {
setError(message(err));
return false;
}
};
const addFiles = async (picked: File[]) => {
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
};
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const card = compact
? "border-b border-border/60 px-1 py-2 last:border-b-0"
: "border-b border-border px-4 py-4 last:border-b-0";
const heading = compact
? "text-xs font-medium text-fg-secondary"
: "text-sm font-medium text-fg-primary";
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
return (
<>
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
{error && (
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
{error}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
>
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={compact ? 4 : 6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.memory")}</h3>
<button
type="button"
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section
data-testid="workspace-context-files"
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
// Files dragged in from the OS are added like the + button's pick.
onDragOver={(e) => {
if (!view || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setCtxDrag(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
}}
onDrop={(e) => {
setCtxDrag(false);
if (!view || e.dataTransfer.files.length === 0) return;
e.preventDefault();
void addFiles([...e.dataTransfer.files]);
}}
>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={(e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
void addFiles(picked);
}}
/>
</div>
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
// Dragged onto a prompt box, it is attached as `@<path>`.
draggable
onDragStart={(e) => dragProjectPath(e, f.path)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className={heading}>{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li
className="flex items-start gap-2 text-xs"
draggable
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
>
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
draggable
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex shrink-0 items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
<div
role="tablist"
aria-label={t("home.memory")}
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
// APG tabs: arrows move (and select), one tab stop.
onKeyDown={(e) => {
const n = memory.length;
const to =
e.key === "ArrowRight"
? (i + 1) % n
: e.key === "ArrowLeft"
? (i - 1 + n) % n
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (to === null) return;
e.preventDefault();
setMemoryTab(to);
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
}}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</button>
))}
</div>
{memory[memoryTab] && (
<div
role="tabpanel"
id="workspace-memory-panel"
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
className="min-h-0 flex-1 overflow-y-auto"
>
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
</>
);
}
+9 -500
View File
@@ -16,40 +16,30 @@
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ArchiveRestore,
ShieldAlert,
FileText,
Folder,
GitBranch,
MoreHorizontal,
Pencil,
ArrowUp,
Plus,
SquareTerminal,
X,
} from "lucide-react";
import {
api,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile,
type LaneSource,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
@@ -58,18 +48,16 @@ import {
import type { Lane, Project, Session } from "../../lib/types";
import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ChatAddMenu } from "../run/ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
import { menuPoint, useContextMenu } from "../ContextMenu";
import {
liveRunFor,
SessionFold,
@@ -87,16 +75,9 @@ export {
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -135,7 +116,6 @@ function HomeBody({
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
@@ -209,27 +189,9 @@ function HomeBody({
// A Recents row being renamed (its session id) and the draft.
const [renamingId, setRenamingId] = useState<string | null>(null);
const [nameDraft, setNameDraft] = useState("");
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const [ctxDrag, setCtxDrag] = useState(false);
const addFiles = async (picked: File[]) => {
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
};
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
@@ -258,25 +220,6 @@ function HomeBody({
`${runKey}:${version}`
);
useEffect(() => {
let live = true;
setView(null);
setMemory(null);
setEditing(null);
setError(null);
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch((err) => live && setError(message(err)));
api.lanes
.memory(lane.id)
.then((r) => live && setMemory(r.files))
.catch(() => live && setMemory([]));
return () => {
live = false;
};
}, [lane.id]);
// The repository this workspace belongs to (Settings → Projects used to
// show this): read once per visit, from the server's cached project list.
const [project, setProject] = useState<Project | null>(null);
@@ -293,57 +236,6 @@ function HomeBody({
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (!live) return;
const p = projects.find((x) => x.workspaces.includes(lane.id));
const own = new Set((view?.sources ?? []).map((s) => s.path));
setSuggestions(
!p || !prefs.showUntracked
? []
: p.worktrees
.filter((w) => w.workspace_id === null && !own.has(w.path))
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
.map((w) => w.path)
);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
useEffect(() => {
if (!sourcesVersion) return;
let live = true;
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch(() => undefined);
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
}, [sourcesVersion]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null);
try {
const next = await fn();
setView(next);
onSourcesChange?.(next);
return true;
} catch (err) {
setError(message(err));
return false;
}
};
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
const openTerminal = async () => {
@@ -466,10 +358,7 @@ function HomeBody({
? t("run:hint.sandboxWarning")
: null;
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const name = workspaceName(lane);
const card = "border-b border-border px-4 py-4 last:border-b-0";
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
@@ -478,7 +367,7 @@ function HomeBody({
data-testid="workspace-home"
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
>
<div className="min-w-0 flex-1 basis-[28rem]">
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
<p className="text-xs text-fg-muted">
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
</p>
@@ -838,363 +727,11 @@ function HomeBody({
</div>
</div>
<aside
data-testid="workspace-context"
aria-label={t("home.contextPanel")}
className="card w-80 shrink-0 self-start p-0"
>
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
<button
type="button"
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section
data-testid="workspace-context-files"
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
// Files dragged in from the OS are added like the + button's pick.
onDragOver={(e) => {
if (!view || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setCtxDrag(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
}}
onDrop={(e) => {
setCtxDrag(false);
if (!view || e.dataTransfer.files.length === 0) return;
e.preventDefault();
void addFiles([...e.dataTransfer.files]);
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={(e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
void addFiles(picked);
}}
/>
</div>
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li className="flex items-start gap-2 text-xs">
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex shrink-0 items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
<div
role="tablist"
aria-label={t("home.memory")}
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
// APG tabs: arrows move (and select), one tab stop.
onKeyDown={(e) => {
const n = memory.length;
const to =
e.key === "ArrowRight"
? (i + 1) % n
: e.key === "ArrowLeft"
? (i - 1 + n) % n
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (to === null) return;
e.preventDefault();
setMemoryTab(to);
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
}}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</button>
))}
</div>
{memory[memoryTab] && (
<div
role="tabpanel"
id="workspace-memory-panel"
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
className="min-h-0 flex-1 overflow-y-auto"
>
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
<WorkspaceContext
lane={lane}
onSourcesChange={onSourcesChange}
sourcesVersion={sourcesVersion}
/>
{recentCtx.menu && (
<SessionMenu
@@ -1212,34 +749,6 @@ function HomeBody({
onArchive={() => void archiveSession(recentCtx.menu!.item)}
/>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
expect(b).not.toBeNull();
return b!;
});
// Inline at the end of the first selected line, never a tooltip over the
// line above it.
expect(chip.closest(".cm-tooltip")).toBeNull();
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
// A tooltip above the last selected line, never inline at a line's end
// (a long line pushed it out of view).
expect(chip.classList.contains("cm-tooltip")).toBe(true);
expect(chip.closest(".cm-line")).toBeNull();
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
@@ -0,0 +1,109 @@
/**
* @file InterruptedSessionsPrompt.test.tsx
* @description The "resume interrupted sessions?" dialog: it opens only when
* the server lists some, resumes only the ticked ones (never a bypass mode
* carried over), and either answer dismisses the list — but a resume that
* failed stays offered.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { InterruptedRun } from "../../../lib/api";
vi.mock("../startSession", () => ({
startInFreeTabDetailed: vi.fn(async (laneId: number) => ({
runId: `agenthub-lane-${laneId}-1`,
laneId,
held: false,
})),
}));
vi.mock("../../../lib/api", () => ({
api: {
run: {
interrupted: vi.fn(),
dismissInterrupted: vi.fn(async () => ({ dismissed: 0 })),
},
},
}));
import { api } from "../../../lib/api";
import { startInFreeTabDetailed } from "../startSession";
import { InterruptedSessionsPrompt } from "../InterruptedSessionsPrompt";
const row = (session_id: string, lane_id: number, extra: Partial<InterruptedRun> = {}) =>
({
id: `agenthub-lane-${lane_id}`,
session_id,
lane_id,
tool: "claude",
cwd: "/r",
model: null,
permission_mode: null,
effort: null,
prompt_preview: `prompt ${session_id}`,
started_at: "2026-10-10T00:00:00Z",
ended_at: "2026-10-10T01:00:00Z",
lane_title: `ws-${lane_id}`,
lane_cwd: "/r",
session_name: null,
...extra,
}) as InterruptedRun;
const list = (items: InterruptedRun[]) =>
vi.mocked(api.run.interrupted).mockResolvedValue({ items });
beforeEach(() => vi.clearAllMocks());
const renderIt = () =>
render(
<MemoryRouter>
<InterruptedSessionsPrompt />
</MemoryRouter>
);
describe("InterruptedSessionsPrompt", () => {
it("shows nothing when no session was interrupted", async () => {
list([]);
renderIt();
await waitFor(() => expect(api.run.interrupted).toHaveBeenCalled());
expect(screen.queryByRole("dialog")).toBeNull();
});
it("resumes only the ticked sessions, then dismisses the list", async () => {
list([row("s-a", 1, { permission_mode: "bypassPermissions", model: "opus" }), row("s-b", 2)]);
const user = userEvent.setup();
renderIt();
await screen.findByRole("dialog");
await user.click(screen.getByRole("checkbox", { name: /ws-2/ }));
await user.click(screen.getByRole("button", { name: /resume 1/i }));
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a", "s-b"]));
expect(startInFreeTabDetailed).toHaveBeenCalledTimes(1);
// The model goes back; a bypass mode is never carried over.
expect(startInFreeTabDetailed).toHaveBeenCalledWith(1, {
resumeSessionId: "s-a",
model: "opus",
});
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("resumes nothing on Don't resume, and keeps a session whose resume failed", async () => {
list([row("s-a", 1)]);
const user = userEvent.setup();
const { unmount } = renderIt();
await user.click(await screen.findByText("Don't resume"));
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a"]));
expect(startInFreeTabDetailed).not.toHaveBeenCalled();
unmount();
vi.clearAllMocks();
list([row("s-a", 1), row("s-b", 2)]);
vi.mocked(startInFreeTabDetailed).mockRejectedValueOnce(new Error("boom"));
renderIt();
await user.click(await screen.findByRole("button", { name: /resume 2/i }));
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-b"]));
expect(screen.getByRole("checkbox", { name: /ws-1/ })).toBeInTheDocument();
});
});
+4 -2
View File
@@ -9,7 +9,7 @@
import i18n from "../../i18n";
import { api, type RunHandle } from "../../lib/api";
import { MAX_SLOTS } from "../../lib/paneLayout";
import { MAX_SLOTS, slotOfKey } from "../../lib/paneLayout";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
@@ -58,7 +58,9 @@ export async function startInFreeTabDetailed(
/** The first pane slot with no live run, or null when all ten are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
runs
.filter((r) => r.laneId === laneId && r.status === "running")
.map((r) => slotOfKey(r.slot ?? 0))
);
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
return null;
+123 -22
View File
@@ -6,18 +6,24 @@
* on the right — Claude's Uploads dialog). Content comes only through routes
* that already guard it: a file inside the workspace through
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
* an attached image or text file through `GET /api/run/drop/:name`, an image
* the session read anywhere else through `GET /api/sessions/:id/image`. Anything
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
* `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
* or the project's); a built-in skill has no file and shows by name only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type DragEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Copy, FileText, Loader2, X } from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
/** Where a Chat pane's files can be read from. */
@@ -31,11 +37,17 @@ export interface PreviewScope {
}
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
// An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js).
const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i;
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
const DROP_RE =
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
/[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
/** A dropped text file shown at most this long. */
const DROP_TEXT_MAX = 200_000;
export const baseName = (p: string) => p.split("/").pop() || p;
/** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */
const slashed = (p: string) => p.replace(/\\/g, "/");
export const baseName = (p: string) => p.split(/[\\/]/).pop() || p;
/** `report.v2.xlsx` → `XLSX`; none → "". */
export const extOf = (p: string) => {
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
@@ -54,37 +66,54 @@ type Content =
function useContent(path: string, scope: PreviewScope): Content {
const [content, setContent] = useState<Content>({ kind: "loading" });
useEffect(() => {
const drop = DROP_RE.exec(path);
if (drop) {
setContent(
IMAGE_RE.test(path)
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
: { kind: "none" }
);
return;
}
let live = true;
const show = (p: Promise<Content>) => {
setContent({ kind: "loading" });
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
};
const base = scope.cwd.replace(/\/+$/, "");
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
// Claude Code's own files (memory): the config pages' guarded read.
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
const drop = DROP_RE.exec(path);
if (drop) {
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
else if (DROP_TEXT_RE.test(path))
show(
fetch(src).then(async (r) => {
if (!r.ok) throw new Error(String(r.status));
const text = await r.text();
return {
kind: "text",
text: text.slice(0, DROP_TEXT_MAX),
truncated: text.length > DROP_TEXT_MAX,
};
})
);
else setContent({ kind: "none" });
return () => {
live = false;
};
}
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
// Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show(
api.ccConfig
.file(path)
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
);
} else if (IMAGE_RE.test(file) && scope.sessionId) {
// An image it read elsewhere (a screenshot under ~/.cache): served
// only because this session's own Read named it.
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
} else setContent({ kind: "none" });
return () => {
live = false;
};
}
const rel = path.slice(base.length + 1);
const rel = file.slice(base.length + 1);
const target =
base === scope.laneCwd.replace(/\/+$/, "")
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) {
@@ -109,7 +138,11 @@ function useContent(path: string, scope: PreviewScope): Content {
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
const { t } = useTranslation("run");
const content = useContent(path, scope);
const loaded = useContent(path, scope);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null);
const content: Content =
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
if (content.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
@@ -123,6 +156,7 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
<img
src={content.src}
alt={baseName(path)}
onError={() => setBroken(content.src)}
className="max-h-full max-w-full object-contain"
/>
</div>
@@ -206,13 +240,19 @@ export function FilePreview({
export function FileListModal({
title,
paths,
label = displayName,
scope,
onClose,
onDragItem,
}: {
title: string;
paths: string[];
/** A row's text; the file name by default. */
label?: (path: string) => string;
scope: PreviewScope;
onClose: () => void;
/** Rows drag into the prompt box; the dialog then closes, out of the way. */
onDragItem?: (e: DragEvent, path: string) => void;
}) {
const [picked, setPicked] = useState(paths[0] ?? "");
const panelRef = useRef<HTMLDivElement>(null);
@@ -241,12 +281,18 @@ export function FileListModal({
<button
type="button"
onClick={() => setPicked(p)}
draggable={!!onDragItem}
onDragStart={(e) => {
onDragItem?.(e, p);
// Not in dragstart itself: removing the source there cancels the drag.
if (onDragItem) window.setTimeout(onClose, 0);
}}
title={p}
aria-current={p === picked}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
>
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 truncate">{displayName(p)}</span>
<span className="min-w-0 truncate">{label(p)}</span>
</button>
</li>
))}
@@ -258,3 +304,58 @@ export function FileListModal({
document.body
);
}
/** The session's skills in the list dialog, each one's SKILL.md previewed. */
export function SkillListModal({
title,
names,
scope,
onClose,
draggable = false,
}: {
title: string;
names: string[];
scope: PreviewScope;
onClose: () => void;
/** Rows drag into the prompt box as `/name`. */
draggable?: boolean;
}) {
const [files, setFiles] = useState<Map<string, string> | null>(null);
useEffect(() => {
let live = true;
void Promise.all([
api.ccConfig.skills(undefined, scope.cwd).catch(() => ({ items: [] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
]).then(([sk, pl]) => {
const found = new Map<string, string>();
const add = (name: string, file: string | undefined) => {
if (file && !found.has(name)) found.set(name, file);
};
for (const p of pl.plugins) {
const items = p.contributes?.items;
for (const it of [...(items?.skills ?? []), ...(items?.commands ?? [])])
add(`${p.name}:${it.name}`, it.file);
}
// A personal skill wins over a project one of the same name, as in Claude Code.
for (const s of sk.items) if (s.scope === "user") add(s.name, s.file);
for (const s of sk.items) add(s.name, s.file);
if (live) setFiles(found);
});
return () => {
live = false;
};
}, [scope.cwd]);
if (!files) return null;
// A built-in skill has no file: its name stands in and previews as nothing.
const byPath = new Map(names.map((n) => [files.get(n) ?? n, n]));
return (
<FileListModal
title={title}
paths={[...byPath.keys()]}
label={(p) => byPath.get(p) ?? displayName(p)}
scope={scope}
onClose={onClose}
onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined}
/>
);
}
+48 -9
View File
@@ -1,20 +1,24 @@
/**
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane shown alone, laid out like
* @description The right-hand panel of a Chat pane, laid out like
* Claude Desktop's activity panel: the session's Progress (its
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
* this session" — the files attached to its prompts, the files it read and the
* memory files it used (a click opens the list with a preview), its skills
* and MCP servers (a click opens the Claude config page). Read from
* memory files it used and its skills (a click opens the list with a
* preview — a skill's SKILL.md), and its MCP servers (a click opens the
* Claude config page). Every row but MCP drags into the prompt box: a file
* as an attachment, a group as all of its files, a lone skill as `/name`.
* Read from
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
* uses for the collapsed button's count.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import {
BookOpen,
Check,
@@ -31,6 +35,7 @@ import {
} from "lucide-react";
import { api, type SessionOutline } from "../../lib/api";
import { displayName, extOf } from "./ChatFilePreview";
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
const POLL_MS = 5000;
const EMPTY: SessionOutline = {
@@ -65,7 +70,21 @@ export function useSessionOutline(sessionId: string): SessionOutline {
return outline;
}
export type OutlineList = "uploads" | "files" | "memory";
export type OutlineList = "uploads" | "files" | "memory" | "skills";
/** A listed file as a prompt attachment: an upload by its bare path, as the
* composer attaches one; anything else as an `@` mention. */
export function outlineAttachment(path: string, upload = false): Attachment {
return { path: upload ? path : `@${path}`, name: displayName(path) };
}
const dragFiles =
(paths: string[], upload = false) =>
(e: DragEvent) =>
dragAttachments(
e,
paths.map((p) => outlineAttachment(p, upload))
);
function fileIcon(path: string) {
const ext = extOf(path).toLowerCase();
@@ -136,12 +155,15 @@ function UsedRow({
label,
names,
onClick,
onDragStart,
to,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
names: string[];
onClick?: () => void;
/** The row drags into the prompt box. */
onDragStart?: (e: DragEvent) => void;
to?: string;
}) {
const shown = names.slice(0, 2).join(", ");
@@ -161,7 +183,14 @@ function UsedRow({
{inner}
</Link>
) : (
<button type="button" title={title} onClick={onClick} className={rowClass}>
<button
type="button"
title={title}
onClick={onClick}
draggable={!!onDragStart}
onDragStart={onDragStart}
className={rowClass}
>
{inner}
</button>
);
@@ -181,6 +210,7 @@ export function ChatOutline({
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const href = useSettingsHref();
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
@@ -198,7 +228,9 @@ export function ChatOutline({
<aside
data-testid="chat-outline"
aria-label={t("chat.outline.panel")}
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
// Over the chat in a narrow pane (the container is PaneChat's root),
// 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"
>
{o.progress.length > 0 && (
<Section title={t("chat.outline.progress")} action={close}>
@@ -241,6 +273,8 @@ export function ChatOutline({
title={p}
aria-label={`${extOf(p)}: ${stem(p)}`}
onClick={() => onOpenFile(p)}
draggable
onDragStart={dragFiles([p])}
className={rowClass}
>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
@@ -261,6 +295,7 @@ export function ChatOutline({
label={t("chat.outline.uploads")}
names={o.uploads.map(displayName)}
onClick={() => onOpenList("uploads")}
onDragStart={dragFiles(o.uploads, true)}
/>
)}
{o.files.length > 0 && (
@@ -269,6 +304,7 @@ export function ChatOutline({
label={t("chat.outline.files")}
names={o.files.map(displayName)}
onClick={() => onOpenList("files")}
onDragStart={dragFiles(o.files)}
/>
)}
{o.memory.length > 0 && (
@@ -277,6 +313,7 @@ export function ChatOutline({
label={t("chat.outline.memory")}
names={o.memory.map(stem)}
onClick={() => onOpenList("memory")}
onDragStart={dragFiles(o.memory)}
/>
)}
{o.skills.length > 0 && (
@@ -284,7 +321,9 @@ export function ChatOutline({
icon={BookOpen}
label={t("chat.skills")}
names={o.skills}
to="/settings/agents/claude-code?tab=skills"
onClick={() => onOpenList("skills")}
// One command per prompt: several are dragged one by one from the list.
onDragStart={o.skills.length === 1 ? (e) => dragSkill(e, o.skills[0]!) : undefined}
/>
)}
{o.mcp.length > 0 && (
@@ -292,7 +331,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>
+78 -10
View File
@@ -26,7 +26,11 @@
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name. Settings
* opened on a workspace is one more tab at the bar's end (`settings`, kept
* until its X), shown instead of the panes while it is the active one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -41,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";
@@ -62,6 +67,7 @@ import {
joinGroup,
leaves,
reorderGroups,
slotOfKey,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -77,7 +83,7 @@ export function runName(
): string {
if (r.tool === "shell") return terminal;
const prompt = r.promptPreview?.trim().split("\n")[0];
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
return session?.name || prompt || `${toolLabel} #${slotOfKey(r.slot ?? 0) + 1}`;
}
export type RunState = "awaiting" | "error" | "running";
@@ -149,6 +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,
@@ -160,10 +169,14 @@ export function ConsoleTabs({
actions,
sessions,
onRenamed,
workspaceOf,
settings,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
/** Tabs from every workspace: the name of the workspace a run belongs to. */
workspaceOf?: (r: RunHandle) => string;
/** The tabs: groups of panes, the active one on screen. */
groups: PaneGroups;
/** Reorders the tabs or takes a pane out into a tab of its own. */
@@ -181,9 +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.
@@ -325,7 +341,10 @@ export function ConsoleTabs({
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
const toolLabel = (r: RunHandle) =>
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
const label = (r: RunHandle) => {
const name = runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
return workspaceOf ? `${workspaceOf(r)} · ${name}` : name;
};
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
// A combo's dot is its most urgent AI pane's; a shell has none.
const tabState = (tab: Tab): RunState | null => {
@@ -388,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
@@ -404,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) => () => {
@@ -447,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) {
@@ -560,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
@@ -609,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")}
@@ -51,8 +51,9 @@ export interface LaneConsolePaneProps {
onRunExit?: () => void;
/** The terminal takes the keyboard when it appears (default). */
autoFocus?: boolean;
/** The only pane shown: its Chat view gets the right panel. */
solo?: boolean;
/** The pane spans the grid's full width or height: its Chat view's right
* panel starts open (collapsed in a quarter-sized pane). */
panelOpen?: boolean;
}
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
@@ -71,7 +72,7 @@ export function LaneConsolePane({
onNewSession,
onRunExit,
autoFocus,
solo = false,
panelOpen = false,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const [handle, setHandle] = useState<RunHandle | null>(null);
@@ -201,7 +202,7 @@ export function LaneConsolePane({
cwd={handle.cwd || currentLane?.cwd || ""}
laneId={laneId}
laneCwd={currentLane?.cwd || ""}
outline={solo}
panelOpen={panelOpen}
/>
</div>
)}
+97 -43
View File
@@ -13,15 +13,24 @@
* Shift+Tab changes in a running session, so the server steps it and reads
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
* Shown alone (`outline`), it also has Claude Desktop's activity panel
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
* file button with the output count until clicked (remembered per browser);
* a file clicked there opens its preview in the panel's place
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
* outputs, what the session used), open at first in a pane spanning the
* grid's full width or height (`panelOpen`) and collapsed into a file button
* with the output count in a quarter-sized one; it sits beside the
* conversation when the pane is wide and over it when narrow (a CSS container
* query on the pane, not the window), dragged wider or narrower by its
* splitter there (a fraction of the pane, remembered per browser). A file
* clicked there opens its preview
* in the panel's place
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
* the workspace's folder —, a group
* of files a list with a preview (`FileListModal`). A file in a turn's
* of files or the used skills a list with a preview (`FileListModal`,
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
* conversation to its end and keeps it there (`follow`), so the message and
* the step running after it come into sight on their own. While the session's
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
@@ -29,16 +38,18 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square } from "lucide-react";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select";
import { Divider } from "./PaneGrid";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
@@ -53,7 +64,12 @@ const MODES: [PermissionMode, string][] = [
["bypassPermissions", "Bypass"],
];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
const SIDE_KEY = "agenthub.chat.panelWidth";
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
const storedSide = () => {
const f = Number(localStorage.getItem(SIDE_KEY));
return f ? clampSide(f) : null;
};
/** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
@@ -109,7 +125,7 @@ export function PaneChat({
cwd,
laneId = null,
laneCwd = "",
outline = false,
panelOpen = false,
}: {
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
sessionId: string;
@@ -117,27 +133,34 @@ export function PaneChat({
/** The workspace and its folder: a file under `cwd` is previewed through it. */
laneId?: number | null;
laneCwd?: string;
/** The pane is shown alone: room for the right panel. */
outline?: boolean;
/** The right panel starts open: the pane spans the grid's width or height. */
panelOpen?: boolean;
}) {
const { t } = useTranslation("run");
const runId = run.id;
const claude = run.tool === "claude";
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
const [uploading, setUploading] = useState(false);
const [follow, setFollow] = useState(0);
const [model, setModel] = useState(run.model ?? "");
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
const pickFiles = useRef<(() => void) | null>(null);
// Collapsed until asked for, like Claude Desktop's activity panel.
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
const [outlineOpen, setOutlineOpen] = useState(panelOpen);
// A split or a close changes the pane's shape: its default comes back.
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
const openOutline = (open: boolean) => {
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
setOutlineOpen(open);
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
// The side panel's width as a fraction of the pane: the dragged one, else
// a default per content (a preview wants more room than the panel).
const [dragged, setDragged] = useState(storedSide);
const side = dragged ?? (preview ? 0.45 : 0.3);
const rootRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
@@ -186,7 +209,12 @@ export function PaneChat({
};
return (
<div className="relative flex min-h-0 flex-1">
<div
ref={rootRef}
data-prompt-drop
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
>
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
<div className="relative min-h-0 flex-1 overflow-hidden">
<ConversationView
@@ -196,19 +224,19 @@ export function PaneChat({
follow={follow}
onOpenFile={(path) => {
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview, when it has room for one.
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
if (outline) setPreview(path);
setPreview(path);
}}
/>
{outline && !outlineOpen && !preview && (
{!outlineOpen && !preview && (
<button
type="button"
data-testid="chat-outline-show"
onClick={() => openOutline(true)}
title={t("chat.outline.show")}
aria-label={t("chat.outline.show")}
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
className="absolute right-3 top-2 flex h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
>
<FileText className="h-3.5 w-3.5" />
{activity.outputs.length}
@@ -291,31 +319,57 @@ export function PaneChat({
</div>
</div>
</div>
{outline &&
(preview ? (
<FilePreview
path={preview}
scope={scope}
onClose={() => setPreview(null)}
// Beside the chat when there is room, over it when not.
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
{preview ? (
<FilePreview
path={preview}
scope={scope}
onClose={() => setPreview(null)}
// Beside the chat when the pane has room, over it when not.
className="absolute inset-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"
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
)
))}
{list && (
)
)}
{(preview || outlineOpen) && (
// Only beside the chat: a panel over a narrow pane has no edge to drag.
<div className="hidden [@container(min-width:40rem)]:contents">
<Divider
gridRef={rootRef}
dir="row"
at={1 - side}
box={{ x: 0, y: 0, w: 1, h: 1 }}
label={t("chat.outline.resize")}
onRatio={(r) => {
const f = clampSide(1 - r);
localStorage.setItem(SIDE_KEY, String(f));
setDragged(f);
}}
/>
</div>
)}
{list === "skills" && (
<SkillListModal
title={t("chat.outline.skills")}
names={activity.skills}
scope={scope}
onClose={() => setList(null)}
draggable={claude}
/>
)}
{list && list !== "skills" && (
<FileListModal
title={t(`chat.outline.${list}`)}
paths={activity[list]}
scope={scope}
onClose={() => setList(null)}
onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])}
/>
)}
</div>
+27 -6
View File
@@ -40,6 +40,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
import {
EMPTY_GROUPS,
type Edge,
type GroupsScope,
type PaneGroups,
type PaneLayout,
type Rect,
@@ -57,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. */
@@ -82,10 +86,10 @@ export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
/** A workspace's tabs (groups of split panes), loaded and saved per
* workspace. An update that lands after a workspace switch never writes into
* the new one. */
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
const load = (id: number | null) => ({
* workspace (or once for every workspace, `all`). An update that lands after
* a workspace switch never writes into the new one. */
export function usePaneGroups(laneId: GroupsScope | null): [PaneGroups, GroupsUpdate] {
const load = (id: GroupsScope | null) => ({
laneId: id,
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
});
@@ -194,7 +198,9 @@ 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. */
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
@@ -208,7 +214,22 @@ function ChatToggle({ run }: { run?: RunHandle }) {
aria-pressed={chat}
title={label}
aria-label={label}
onClick={() => setSessionChat(sessionId, !chat)}
onClick={() => {
const draft = readChatDraft(sessionId);
if (chat) {
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []);
} else {
const text = takeRunInput(run.id);
if (text)
writeChatDraft(
sessionId,
[draft.prompt, text].filter(Boolean).join("\n"),
draft.images
);
}
setSessionChat(sessionId, !chat);
}}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
{chat ? (
+119 -49
View File
@@ -9,8 +9,11 @@
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a Files panel row
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh;
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
@@ -60,10 +63,13 @@ import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
droppedAttachment,
droppedAttachments,
droppedSkill,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -221,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(() => {
@@ -229,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[]>([]);
@@ -388,6 +407,45 @@ export function PromptEditor({
const fileInput = useRef<HTMLInputElement | null>(null);
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
// A drop lands anywhere on the screen around the composer — the nearest
// `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home),
// else a `bare` editor's parent, the box every caller draws around it and
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
// `/command`, a file is uploaded.
const rootRef = useRef<HTMLDivElement | null>(null);
const dropRef = useRef<(e: DragEvent) => void>(() => {});
dropRef.current = (e) => {
const atts = droppedAttachments(e.dataTransfer);
const skill = droppedSkill(e.dataTransfer);
if (atts.length) {
e.preventDefault();
onImagesChange((prev) => addAttachments(prev, atts));
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
};
useEffect(() => {
const zone =
rootRef.current?.closest<HTMLElement>("[data-prompt-drop]") ??
(bare ? rootRef.current?.parentElement : rootRef.current);
if (!zone) return;
const over = (e: DragEvent) => {
const types = e.dataTransfer?.types ?? [];
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
e.preventDefault();
e.dataTransfer!.dropEffect = "copy";
};
const drop = (e: DragEvent) => dropRef.current(e);
zone.addEventListener("dragover", over);
zone.addEventListener("drop", drop);
return () => {
zone.removeEventListener("dragover", over);
zone.removeEventListener("drop", drop);
};
}, [bare]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
@@ -431,7 +489,7 @@ export function PromptEditor({
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
return (
<div className="relative">
<div ref={rootRef} className="relative">
{images.length > 0 && (
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
{images.map((img) => (
@@ -503,52 +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" })]);
}
}
}
}}
onDragOver={(e) => {
// A Files panel row is not a file: say a drop is welcome.
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
}}
onDrop={(e) => {
const att = droppedAttachment(e.dataTransfer);
if (att) {
e.preventDefault();
onImagesChange((prev) => addAttachments(prev, [att]));
return;
}
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={
bare
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
}
/>
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
/>
{marked && (
<div
ref={mirrorRef}
aria-hidden
data-testid="prompt-mirror"
// Its own scrollbar keeps the width the textarea's takes, but is
// invisible, so the real one shows through.
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
>
{tokens.map((tok, i) =>
tok.kind === "text" ? (
tok.text
) : (
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
{tok.text}
</span>
)
)}
{/* A trailing newline is a line in a textarea, not in a div. */}
{"\u200b"}
</div>
)}
</div>
{upload && (
<p
role={upload === "failed" ? "alert" : "status"}
@@ -562,6 +562,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]
@@ -2,14 +2,17 @@
* @file ChatFilePreview.test.tsx
* @description Where a Chat pane's file preview reads from: a file under the
* pane's folder through the lane's git file read, a memory file under
* `~/.claude` through the config pages' read, anything else by path only; and
* the list dialog previewing the file picked in it.
* `~/.claude` through the config pages' read, a dropped text file through the
* drop route, an image read outside the folder through the session's image
* route, a Windows path like a POSIX one, anything else by path only; the
* list dialog previewing the file picked in it; and the skills dialog finding
* each skill's SKILL.md by name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
import { FileListModal, FilePreview, SkillListModal, displayName } from "../ChatFilePreview";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -28,7 +31,30 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}),
gitRawUrl: () => "",
},
sessions: { readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}` },
ccConfig: {
skills: vi.fn().mockResolvedValue({
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
}),
plugins: vi.fn().mockResolvedValue({
plugins: [
{
name: "agenthub",
contributes: {
items: {
skills: [
{
name: "file-headers",
description: null,
file: "/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md",
},
],
commands: [],
},
},
},
],
}),
file: vi
.fn()
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
@@ -53,6 +79,37 @@ describe("FilePreview", () => {
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
});
it("reads Windows paths the same way", async () => {
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
cleanup();
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
render(<FilePreview path={tool} scope={win} onClose={() => {}} />);
expect(await screen.findByText("Launch notes")).toBeTruthy();
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(tool);
expect(screen.getByText("b")).toBeTruthy(); // the name, not the whole path
});
it("reads a dropped text file through the drop route", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("a long paste"));
vi.stubGlobal("fetch", fetchMock);
const drop = "C:\\Users\\k\\.claude\\agenthub\\drops\\0123456789abcdef-pasted-text.txt";
render(<FilePreview path={drop} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("a long paste")).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith("/api/run/drop/0123456789abcdef-pasted-text.txt");
vi.unstubAllGlobals();
});
it("shows an image it read outside the folder through the session, its path if refused", () => {
render(<FilePreview path="/h/.cache/shot.png" scope={scope} onClose={() => {}} />);
const img = screen.getByAltText("shot.png") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("/img/s1?path=/h/.cache/shot.png");
fireEvent.error(img);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
it("shows only the path of a file it may not read", () => {
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
@@ -76,3 +133,28 @@ describe("FileListModal", () => {
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
});
});
describe("SkillListModal", () => {
it("lists the skills by name and previews each one's SKILL.md", async () => {
render(
<SkillListModal
title="Skills"
names={["agenthub:file-headers", "xlsx", "simplify"]}
scope={scope}
onClose={() => {}}
/>
);
// A plugin's skill first, read through the config read.
expect(await screen.findByRole("button", { name: "agenthub:file-headers" })).toBeTruthy();
await screen.findByText("Launch notes");
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(
"/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md"
);
fireEvent.click(screen.getByRole("button", { name: "xlsx" }));
await screen.findByText("Launch notes");
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith("/h/.claude/skills/xlsx/SKILL.md");
// A built-in skill has no file.
fireEvent.click(screen.getByRole("button", { name: "simplify" }));
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
});
@@ -3,7 +3,7 @@
* @description The Chat pane's activity panel renders a session's outline the
* way Claude Desktop does: progress with done/current items, outputs by name
* with an EXT tag (a click opens that file), and "Used in this session" rows —
* uploads, files read and memory open their list, skills link to the config.
* uploads, files read, memory and skills open their list.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -52,15 +52,47 @@ describe("ChatOutline", () => {
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
fireEvent.click(screen.getByTitle("Memory: release"));
expect(onOpenList).toHaveBeenCalledWith("memory");
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
"/settings/agents/claude-code?tab=skills"
);
fireEvent.click(screen.getByTitle("Skills: xlsx"));
expect(onOpenList).toHaveBeenCalledWith("skills");
expect(screen.getByText("vercel_vercel")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
expect(onClose).toHaveBeenCalled();
});
it("drags its rows into the prompt box", () => {
render(
<MemoryRouter>
<ChatOutline
outline={outline}
onClose={() => {}}
onOpenFile={() => {}}
onOpenList={() => {}}
/>
</MemoryRouter>
);
const dragged = (el: HTMLElement) => {
const data: Record<string, string> = {};
fireEvent.dragStart(el, {
dataTransfer: { setData: (k: string, v: string) => (data[k] = v) },
});
return data;
};
const attached = (el: HTMLElement) =>
JSON.parse(dragged(el)["application/x-agenthub-attach"] ?? "[]") as { path: string }[];
expect(attached(screen.getByRole("button", { name: "XLSX: Progress report" }))).toEqual([
{ path: "@/w/out/Progress report.xlsx", name: "Progress report.xlsx" },
]);
expect(attached(screen.getByTitle(/a\.ts, /)).map((a) => a.path)).toEqual([
"@/w/src/a.ts",
"@/w/src/b.ts",
"@/w/src/c.ts",
]);
// An upload goes as the composer attaches one: its bare path.
expect(attached(screen.getByTitle("Uploads: minutes.xlsx"))[0]!.path).toBe(outline.uploads[0]);
expect(dragged(screen.getByTitle("Skills: xlsx"))["application/x-agenthub-skill"]).toBe("xlsx");
});
it("hides progress and the used section when there is none", () => {
render(
<MemoryRouter>
@@ -122,7 +122,11 @@ function renderChat(run: Partial<typeof RUN> = {}) {
);
}
beforeEach(() => vi.mocked(submitToRun).mockClear());
beforeEach(() => {
vi.mocked(submitToRun).mockClear();
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
localStorage.removeItem("agenthub.chat.draft.s1");
});
describe("PaneChat — the + menu", () => {
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
@@ -230,10 +234,9 @@ const byId = (id: string) => () => screen.getByTestId(id);
describe("PaneChat — the activity panel", () => {
it("opens from the file button, previews a file, and closes back to the panel", async () => {
localStorage.removeItem("agenthub.chat.outlineOpen");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
</MemoryRouter>
);
expect(screen.queryByTestId("chat-outline")).toBeNull();
@@ -248,7 +251,33 @@ describe("PaneChat — the activity panel", () => {
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
});
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
const pane = (panelOpen: boolean) => (
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
</MemoryRouter>
);
const { rerender } = render(pane(true));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
rerender(pane(false));
expect(screen.queryByTestId("chat-outline")).toBeNull();
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
});
it("resizes from its splitter and remembers the width", () => {
localStorage.removeItem("agenthub.chat.panelWidth");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
</MemoryRouter>
);
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
expect(splitter.getAttribute("aria-valuenow")).toBe("65");
});
});
@@ -259,7 +288,7 @@ describe("PaneChat — an edited file", () => {
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("edited-in"));
@@ -272,3 +301,36 @@ describe("PaneChat — an edited file", () => {
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
});
});
describe("PaneChat — the unsent prompt", () => {
it("keeps the half-typed prompt and its attachments when the chat unmounts (terminal view, reload)", async () => {
const first = renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
target: { value: "half typed" },
});
// A dropped attachment, as a Files row or an "Edited N files" row brings it.
const drop = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(drop, "dataTransfer", {
value: {
types: ["application/x-agenthub-attach"],
getData: (t: string) =>
t === "application/x-agenthub-attach"
? JSON.stringify({ path: "@src/a.ts", name: "a.ts" })
: "",
files: [],
},
});
act(() => {
screen.getByPlaceholderText("Write a message…").dispatchEvent(drop);
});
expect(await screen.findByText("a.ts")).toBeInTheDocument();
first.unmount();
renderChat();
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
});
@@ -5,7 +5,7 @@
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path (and
* remembered as a recent upload), a Files panel row dropped as an `@path`
* chip, a composer
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
@@ -16,7 +16,7 @@
*/
import { useState } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import {
@@ -28,7 +28,13 @@ import {
type PromptImage,
} from "../RunSetup";
import { api } from "../../../lib/api";
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
import {
ATTACH_MIME,
dragAttachments,
dragProjectPath,
dragSkill,
recentUploads,
} from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -111,6 +117,31 @@ function renderEditor(value: string, slash = true) {
const box = () => screen.getByPlaceholderText("Ask anything…");
describe("PromptEditor — / and @ suggestions", () => {
it("colours /command and @path tokens through a mirror, only when there are any", () => {
const { unmount } = render(
<PromptEditor
value={"/review @src/a.ts please"}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
onChange={() => {}}
onSubmit={() => {}}
/>
);
const mirror = screen.getByTestId("prompt-mirror");
expect(mirror).toHaveAttribute("aria-hidden");
expect(within(mirror).getByText("/review").className).toContain("text-accent");
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
expect(box().className).toContain("text-transparent");
unmount();
renderEditor("plain words");
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
expect(box().className).toContain("text-fg-primary");
});
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const spies = renderEditor("");
fireEvent.change(box(), { target: { value: "/rev" } });
@@ -348,6 +379,134 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
});
it("takes a drop anywhere on a bare editor's box; an extra folder's row comes absolute", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<div>
<PromptEditor
bare
slash={false}
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<div data-testid="toolbar" />
</div>
<section data-drag-root="/Users/tester/extra/">
<span data-testid="row" />
</section>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const data: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (data[k] = v),
getData: (k: string) => data[k] ?? "",
get types() {
return Object.keys(data);
},
files: [],
};
dragProjectPath(
{ dataTransfer, currentTarget: screen.getByTestId("row") } as unknown as React.DragEvent,
"notes.md"
);
expect(data["text/plain"]).toBe("/Users/tester/extra/notes.md");
const over = createEvent.dragOver(screen.getByTestId("toolbar"), { dataTransfer });
fireEvent(screen.getByTestId("toolbar"), over);
expect(over.defaultPrevented).toBe(true);
fireEvent.drop(screen.getByTestId("toolbar"), { dataTransfer });
expect(screen.getByTestId("sent").textContent).toBe("read @/Users/tester/extra/notes.md");
});
it("takes a drop anywhere inside its data-prompt-drop screen, not only on the box", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<div data-prompt-drop>
<div data-testid="transcript" />
<PromptEditor
value="see"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</div>
);
}
render(<Harness />);
const dataTransfer = {
getData: (k: string) =>
k === ATTACH_MIME ? JSON.stringify([{ path: "@/w/a.ts", name: "a.ts" }]) : "",
types: [ATTACH_MIME],
files: [],
};
const over = createEvent.dragOver(screen.getByTestId("transcript"), { dataTransfer });
fireEvent(screen.getByTestId("transcript"), over);
expect(over.defaultPrevented).toBe(true);
fireEvent.drop(screen.getByTestId("transcript"), { dataTransfer });
expect(screen.getByTestId("sent").textContent).toBe("see @/w/a.ts");
});
it("takes an activity panel's drag: a group of files as chips, a skill as the /command", () => {
function Harness() {
const [value, setValue] = useState("check it");
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
slash={false}
value={value}
onChange={setValue}
onSubmit={() => {}}
placeholder="Ask anything…"
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages(value, images)}</output>
</>
);
}
render(<Harness />);
const drag = (start: (e: React.DragEvent) => void) => {
const data: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (data[k] = v),
getData: (k: string) => data[k] ?? "",
get types() {
return Object.keys(data);
},
files: [],
};
start({ dataTransfer } as unknown as React.DragEvent);
fireEvent.drop(box(), { dataTransfer });
};
drag((e) =>
dragAttachments(e, [
{ path: "@/a/x.md", name: "x.md" },
{ path: "/d/drops/0123456789abcdef.png", name: "y.png" },
])
);
drag((e) => dragSkill(e, "ponytail:ponytail-help"));
expect(screen.getByTestId("sent").textContent).toBe(
"/ponytail:ponytail-help check it @/a/x.md /d/drops/0123456789abcdef.png"
);
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), {
@@ -0,0 +1,171 @@
/**
* @file AgentInstructionsSettings — Settings → AI tools → My agent
* instructions (issue #67): the account's own rules in markdown, put before
* every workspace's own instructions in each new Claude or Codex session of a
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
* failed load says so with a Retry instead of leaving a dead, empty editor —
* the editor stays read-only until the saved text is known, so a save can
* never overwrite instructions it did not load. **Choose file** reads a local
* Markdown/text file in the browser into the draft (nothing is uploaded until
* Save, and Discard brings the saved text back). "Applies to" lists only the
* installed, enabled tools whose panes the server says receive them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { FileUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api, type AgentInstructionsView } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { toast } from "../../lib/toast";
import { SettingsSection } from "./SettingsRow";
export function AgentInstructionsSettings() {
const { t } = useTranslation("settings");
const [saved, setSaved] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [max, setMax] = useState<number | undefined>();
const [toolIds, setToolIds] = useState<string[]>([]);
const { tools } = useAiTools();
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const fileInput = useRef<HTMLInputElement | null>(null);
const apply = (r: AgentInstructionsView) => {
setSaved(r.instructions);
setDraft(r.instructions);
setMax(r.max);
setToolIds(r.tools ?? []);
};
const load = () => {
setLoadError(null);
api.me
.instructions()
.then(apply)
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
};
useEffect(load, []);
async function save() {
setSaving(true);
try {
apply(await api.me.saveInstructions(draft));
toast.success(t("agentInstructions.saved"));
} catch (err) {
toast.error(
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
);
} finally {
setSaving(false);
}
}
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again after a Discard
if (!file) return;
const text = await file.text();
if (max && text.length > max) {
toast.error(
t("agentInstructions.fileTooBig", {
name: file.name,
size: text.length.toLocaleString(),
max: max.toLocaleString(),
})
);
return;
}
setDraft(text);
}
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
const ready = saved !== null;
const dirty = ready && draft !== saved;
return (
<SettingsSection
id="agent-instructions"
title={t("agentInstructions.title")}
description={t("agentInstructions.description")}
>
<div className="pt-1">
{appliesTo.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
<span>{t("agentInstructions.appliesTo")}</span>
{appliesTo.map((x) => (
<span
key={x.id}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
>
<AiToolIcon tool={x} className="h-4 w-4" />
{x.label}
</span>
))}
</div>
)}
<textarea
aria-label={t("agentInstructions.title")}
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
rows={8}
spellCheck={false}
placeholder={t("agentInstructions.placeholder")}
value={draft}
maxLength={max}
disabled={!ready}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="mt-3 flex items-center justify-between gap-3">
{loadError ? (
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
<span className="truncate">
{t("agentInstructions.loadFailed", { message: loadError })}
</span>
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
{t("common:retry")}
</button>
</p>
) : (
<span className="text-[11px] tabular-nums text-fg-muted">
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
</span>
)}
<div className="flex shrink-0 items-center gap-2">
<input
ref={fileInput}
type="file"
accept=".md,.markdown,.txt,text/markdown,text/plain"
className="hidden"
data-testid="agent-instructions-file"
onChange={(e) => void pickFile(e)}
/>
<button
type="button"
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
disabled={!ready || saving}
onClick={() => fileInput.current?.click()}
>
<FileUp className="h-3.5 w-3.5" />
{t("agentInstructions.chooseFile")}
</button>
{dirty && !saving && (
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
{t("agentInstructions.discard")}
</button>
)}
<button
type="button"
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
disabled={saving || !dirty}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
</div>
</div>
</SettingsSection>
);
}
@@ -1,7 +1,9 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the per-user
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`), the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
@@ -17,7 +19,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings";
import { Select } from "../Select";
@@ -31,6 +33,7 @@ import {
writeTerminalFont,
type TerminalFont,
} from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
export const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
@@ -79,6 +82,7 @@ export function AppearanceSettings() {
const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const [termFont, setTermFont] = useState(readTerminalFont);
const [allTabs, setAllTabs] = useState(readAllTabs);
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
setTermFont(font);
@@ -101,6 +105,22 @@ export function AppearanceSettings() {
<div className="space-y-6">
<ChatUiSettings />
<SettingsSection
id="workspace-tabs"
title={t("workspaceTabs.title")}
description={t("workspaceTabs.description")}
>
<SettingsSwitchRow
label={t("workspaceTabs.label")}
description={t("workspaceTabs.labelDesc")}
checked={allTabs}
onChange={(on) => {
writeAllTabs(on);
setAllTabs(on);
}}
/>
</SettingsSection>
<SettingsSection
id="language"
title={t("language.title")}
@@ -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
View File
@@ -384,6 +384,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",
+10
View File
@@ -106,6 +106,7 @@
"outputs": "Outputs",
"connectors": "Connectors",
"empty": "Nothing yet",
"resize": "Resize side panel",
"hide": "Hide side panel",
"show": "Activity panel",
"copyPath": "Copy path: {{path}}",
@@ -114,9 +115,18 @@
"uploads": "Uploads",
"files": "Files read",
"memory": "Memory",
"skills": "Skills",
"close": "Close",
"noPreview": "No preview for this file here",
"truncated": "Showing the first part of this file"
}
},
"interrupted": {
"title": "Resume interrupted sessions?",
"message": "These sessions stopped unexpectedly (the machine shut down, tmux was killed, or the CLI crashed). Tick the ones to resume — each re-reads its whole conversation.",
"resume_one": "Resume {{count}} session",
"resume_other": "Resume {{count}} sessions",
"none": "Don't resume",
"failed": "Couldn't resume {{name}}: {{message}}"
}
}
+18
View File
@@ -895,6 +895,12 @@
"vi": "Tiếng Việt"
}
},
"workspaceTabs": {
"title": "Workspace tabs",
"description": "Which sessions the session view's tab bar holds. Saved to your account.",
"label": "Tabs from every workspace",
"labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions."
},
"terminalFont": {
"title": "Terminal font",
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
@@ -1026,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
View File
@@ -384,6 +384,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",
+10
View File
@@ -106,6 +106,7 @@
"outputs": "Kết quả",
"connectors": "Kết nối",
"empty": "Chưa có gì",
"resize": "Kéo giãn thanh bên",
"hide": "Ẩn thanh bên",
"show": "Bảng hoạt động",
"copyPath": "Sao chép đường dẫn: {{path}}",
@@ -114,9 +115,18 @@
"uploads": "Tệp tải lên",
"files": "Tệp đã đọc",
"memory": "Bộ nhớ",
"skills": "Skills",
"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"
}
},
"interrupted": {
"title": "Tiếp tục các phiên bị gián đoạn?",
"message": "Các phiên này dừng ngoài ý muốn (tắt máy ngang, tmux bị tắt, hoặc CLI bị lỗi). Đánh dấu những phiên muốn tiếp tục — mỗi phiên sẽ đọc lại toàn bộ hội thoại.",
"resume_one": "Tiếp tục {{count}} phiên",
"resume_other": "Tiếp tục {{count}} phiên",
"none": "Không tiếp tục",
"failed": "Không tiếp tục được {{name}}: {{message}}"
}
}
+18
View File
@@ -895,6 +895,12 @@
"vi": "Tiếng Việt"
}
},
"workspaceTabs": {
"title": "Tab của workspace",
"description": "Thanh tab của màn hình session chứa những session nào. Lưu theo tài khoản.",
"label": "Tab của mọi workspace",
"labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn."
},
"terminalFont": {
"title": "Font terminal",
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
@@ -1026,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"
}
}
+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");
});
});
+46
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" }),
},
@@ -922,6 +930,10 @@ export const api = {
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
* and the skills / MCP servers it used (a Chat pane's side panel). */
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
/** GET /api/sessions/:id/image — an image the session's Read tool read,
* wherever it lies (local mode only), for an `<img>`. */
readImageUrl: (id: string, path: string) =>
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean }) =>
@@ -2499,6 +2511,15 @@ export const api = {
/** DELETE /api/run/:id - kill the tmux session. */
kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
/** GET /api/run/interrupted - sessions whose pane died under them (reboot,
* killed tmux, crash), newest first; the caller's own workspaces only. */
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
/** POST /api/run/interrupted/dismiss - stop offering these sessions. */
dismissInterrupted: (sessionIds: string[]) =>
request<{ dismissed: number }>("/run/interrupted/dismiss", {
method: "POST",
body: JSON.stringify({ sessionIds }),
}),
},
// ────────────────────────────────── Alerts API ──────────────────────────────
@@ -3553,6 +3574,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;
@@ -4369,6 +4397,24 @@ export interface RunStartArgs {
proxy?: string;
}
/** One session an unexpected stop left behind (GET /api/run/interrupted). */
export interface InterruptedRun {
id: string;
session_id: string;
lane_id: number;
tool: LaneTool;
cwd: string;
model: string | null;
permission_mode: PermissionMode | null;
effort: EffortLevel | null;
prompt_preview: string | null;
started_at: string;
ended_at: string | null;
lane_title: string;
lane_cwd: string;
session_name: string | null;
}
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
* name (`agenthub-lane-<laneId>`). */
export interface RunHandle {
+50
View File
@@ -0,0 +1,50 @@
/**
* @file chatDraft.ts — a Chat pane's unsent prompt (`PaneChat`): its text and
* attachments, kept per SESSION in this browser's localStorage
* (`agenthub.chat.draft.<sessionId>`), so it survives the pane switching to
* its terminal (which unmounts the chat) and a page reload — as the terminal's
* own half-typed line survives in tmux. An image's preview (a blob URL) does
* not outlive the page, so a restored attachment keeps only its path and name.
* Cleared once the prompt is sent (an empty draft removes the key).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { PromptImage } from "../components/run/RunSetup";
const key = (sessionId: string) => `agenthub.chat.draft.${sessionId}`;
export interface ChatDraft {
prompt: string;
images: PromptImage[];
}
export function readChatDraft(sessionId: string): ChatDraft {
try {
const v = JSON.parse(
localStorage.getItem(key(sessionId)) || "null"
) as Partial<ChatDraft> | null;
return {
prompt: typeof v?.prompt === "string" ? v.prompt : "",
images: Array.isArray(v?.images)
? v.images
.filter((i) => typeof i?.path === "string" && typeof i.name === "string")
.map((i) => ({ path: i.path, name: i.name }))
: [],
};
} catch {
return { prompt: "", images: [] };
}
}
export function writeChatDraft(sessionId: string, prompt: string, images: PromptImage[]) {
try {
if (!prompt && !images.length) localStorage.removeItem(key(sessionId));
else
localStorage.setItem(
key(sessionId),
JSON.stringify({ prompt, images: images.map(({ path, name }) => ({ path, name })) })
);
} catch {
/* storage full or blocked: the draft lives for this page only */
}
}
+24 -11
View File
@@ -17,6 +17,10 @@
* its session back in a tab of its own. The tabs are a viewer preference,
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
* stored pre-tab single layout reads as one tab), never on the server.
*
* With Settings → Appearance → "Tabs from every workspace" on, ONE set of tabs
* (stored under `all`) holds every workspace's sessions: a leaf is then a
* `paneKey` (lane id × `MAX_SLOTS` + slot) instead of a bare slot.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,6 +28,12 @@
export const MAX_PANES = 4;
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
export const MAX_SLOTS = 10;
/** A pane's id in the every-workspace tabs: unique across lanes, still a number. */
export const paneKey = (laneId: number, slot: number) => laneId * MAX_SLOTS + slot;
/** The slot of a leaf — itself, or a `paneKey`'s slot part. */
export const slotOfKey = (key: number) => key % MAX_SLOTS;
/** The tabs' storage name: a lane id, or `all` for the every-workspace tabs. */
export type GroupsScope = number | "all";
const MIN_RATIO = 0.1;
const STORAGE_KEY = "agenthub.workspace.paneLayout";
@@ -223,17 +233,17 @@ export function layoutRects(node: PaneNode | null): {
return { panes, dividers };
}
function parseNode(raw: unknown): PaneNode | null {
function parseNode(raw: unknown, max: number): PaneNode | null {
if (!raw || typeof raw !== "object") return null;
const n = raw as Record<string, unknown>;
if (n.type === "pane") {
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_SLOTS
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < max
? pane(n.slot as number)
: null;
}
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
const a = parseNode(n.a);
const b = parseNode(n.b);
const a = parseNode(n.a, max);
const b = parseNode(n.b, max);
if (!a || !b) return null;
const ratio = typeof n.ratio === "number" && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5;
return split(n.dir, a, b, ratio);
@@ -241,9 +251,9 @@ function parseNode(raw: unknown): PaneNode | null {
/** A stored layout, checked: anything malformed, a slot out of range, a slot
* shown twice or more than `MAX_PANES` panes is no layout at all. */
export function parseLayout(raw: unknown): PaneLayout {
export function parseLayout(raw: unknown, max = MAX_SLOTS): PaneLayout {
if (!raw || typeof raw !== "object") return EMPTY;
const root = parseNode((raw as { root?: unknown }).root);
const root = parseNode((raw as { root?: unknown }).root, max);
const shown = leaves(root);
if (!root || shown.length > MAX_PANES || new Set(shown).size !== shown.length) return EMPTY;
const focus = (raw as { focus?: unknown }).focus;
@@ -389,7 +399,7 @@ export function pruneGroups(g: PaneGroups, live: Set<number>, keep: Set<number>)
/** Stored tabs, checked: a malformed tab, or one showing a session an
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
export function parseGroups(raw: unknown): PaneGroups {
export function parseGroups(raw: unknown, max = MAX_SLOTS): PaneGroups {
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
const r = raw as { groups?: unknown; active?: unknown };
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
@@ -400,7 +410,7 @@ export function parseGroups(raw: unknown): PaneGroups {
// a dropped tab ahead of it does not shift the selection onto its neighbour.
let active = 0;
list.forEach((item, i) => {
const l = parseLayout(item);
const l = parseLayout(item, max);
const shown = leaves(l.root);
if (!l.root || shown.some((s) => seen.has(s))) return;
shown.forEach((s) => seen.add(s));
@@ -420,11 +430,14 @@ function readAll(): Record<string, unknown> {
}
}
export function loadGroups(laneId: number): PaneGroups {
return parseGroups(readAll()[String(laneId)]);
export function loadGroups(laneId: GroupsScope): PaneGroups {
return parseGroups(
readAll()[String(laneId)],
laneId === "all" ? Number.MAX_SAFE_INTEGER : MAX_SLOTS
);
}
export function saveGroups(laneId: number, g: PaneGroups): void {
export function saveGroups(laneId: GroupsScope, g: PaneGroups): void {
const all = readAll();
if (g.groups.length) all[String(laneId)] = g;
else delete all[String(laneId)];
+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;
}
+49 -10
View File
@@ -2,7 +2,8 @@
* @file promptAttach.ts
* @description What a prompt box can attach besides a fresh upload: a project
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
* `PromptEditor`'s drop) or from a Chat pane's activity panel
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
* last day (`recentUploads`, kept in this browser). An attachment is a path
* sent with the prompt, so nothing here touches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -26,21 +27,59 @@ export function projectAttachment(rel: string): Attachment {
}
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
* text too, so a drop elsewhere (a terminal, an editor) gets the path. A row
* under a `data-drag-root` (an extra folder's section) lies outside the
* pane's folder, so it carries its absolute path instead. */
export function dragProjectPath(e: DragEvent, rel: string) {
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
e.dataTransfer.setData("text/plain", rel);
const root = (e.currentTarget as HTMLElement | null)?.closest?.<HTMLElement>("[data-drag-root]")
?.dataset.dragRoot;
const path = root ? `${root.replace(/[\\/]+$/, "")}/${rel}` : rel;
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(path)));
e.dataTransfer.setData("text/plain", path);
e.dataTransfer.effectAllowed = "copy";
}
/** The attachment a drop carries, if it is one of ours. */
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
/** Start a drag carrying attachments as they are (an activity panel's files,
* already absolute); plain text too, like `dragProjectPath`. */
export function dragAttachments(e: DragEvent, items: Attachment[]) {
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(items));
e.dataTransfer.setData("text/plain", items.map((a) => a.path.replace(/^@/, "")).join(" "));
e.dataTransfer.effectAllowed = "copy";
}
/** The attachments a drop carries, if they are ours: one, or a list. */
export function droppedAttachments(dt: DataTransfer | null | undefined): Attachment[] {
try {
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
const a = v as Attachment | null;
return a && typeof a.path === "string" && typeof a.name === "string"
? { path: a.path, name: a.name }
: null;
return (Array.isArray(v) ? v : [v])
.filter(
(a): a is Attachment =>
!!a &&
typeof (a as Attachment).path === "string" &&
typeof (a as Attachment).name === "string"
)
.map((a) => ({ path: a.path, name: a.name }));
} catch {
return [];
}
}
export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
/** Start a drag carrying a skill; dropped on a prompt box it becomes `/name`. */
export function dragSkill(e: DragEvent, name: string) {
e.dataTransfer.setData(SKILL_MIME, name);
e.dataTransfer.setData("text/plain", `/${name}`);
e.dataTransfer.effectAllowed = "copy";
}
/** The skill a drop carries, if it is one of ours. */
export function droppedSkill(dt: DataTransfer | null | undefined): string | null {
try {
const name = dt?.getData(SKILL_MIME) ?? "";
return SKILL_RE.test(name) ? name : null;
} catch {
return null;
}
+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);
}
+7
View File
@@ -41,6 +41,13 @@ export const SETTINGS_INDEX: {
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{
section: "appearance",
titleKey: "workspaceTabs.title",
descKey: "workspaceTabs.description",
keywords: ["tab", "workspace", "session", "combo", "split", "không gian làm việc"],
to: "#workspace-tabs",
},
{
section: "appearance",
titleKey: "terminalFont.title",
+3 -1
View File
@@ -1920,7 +1920,9 @@ export interface WSMessage {
/** A lane's stack finished coming up (or failed to), with fresh facts. */
| "lane_runtime"
/** A lane's profile hook exited, with its status code. */
| "lane_hook_result";
| "lane_hook_result"
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
| "runs_interrupted";
/** The message body, whose concrete shape is selected by `type` above. */
data:
| Session
+1
View File
@@ -35,6 +35,7 @@ export const PREF_KEYS = [
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.tour",
"agenthub.workspace.allTabs",
] as const;
const SYNCED = new Set<string>(PREF_KEYS);
const PUSH_DELAY_MS = 400;
+28
View File
@@ -0,0 +1,28 @@
/**
* @file workspaceTabs — Settings → Appearance → "Tabs from every workspace": a
* viewer preference in `localStorage` (`agenthub.workspace.allTabs`, synced to
* the account through `lib/userPrefs.ts`). On, the session view's tab bar
* holds every workspace's live sessions and a combo may mix them; off (the
* default), it shows only the selected workspace's. Read when the Workspace
* page mounts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const ALL_TABS_KEY = "agenthub.workspace.allTabs";
export function readAllTabs(): boolean {
try {
return window.localStorage.getItem(ALL_TABS_KEY) === "1";
} catch {
return false;
}
}
export function writeAllTabs(on: boolean) {
try {
if (on) window.localStorage.setItem(ALL_TABS_KEY, "1");
else window.localStorage.removeItem(ALL_TABS_KEY);
} catch {
/* storage blocked — the default */
}
}
+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>
+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 ${
+243 -66
View File
@@ -19,7 +19,12 @@
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
* closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Errors are toasts.
* 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>
*/
@@ -95,28 +100,37 @@ import {
} from "../components/run/PaneGrid";
import {
type Edge,
type GroupsScope,
type PaneGroups,
type PaneLayout,
MAX_PANES,
MAX_SLOTS,
activeLayout,
closeInLayout,
groupOf,
layoutRects,
leaves,
moveInLayout,
openInGroups,
pane,
paneKey,
pruneGroups,
removeSlot,
slotOfKey,
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, startInFreeTab } from "../components/lanes/startSession";
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
@@ -130,6 +144,8 @@ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the last width the user dragged the panel to — what
* it reopens at after the step control closes it. */
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
/** localStorage key for whether the Changes panel's Workspace context fold is open. */
const CONTEXT_OPEN_KEY = "agenthub.workspace.contextOpen";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
@@ -178,7 +194,11 @@ function slotOf(runId: string | null | undefined): number {
* into the tab on screen, at the 2×2 grid's next place. Every live run
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
* run ends closes; the last tab gone leaves for the home. */
* run ends closes; the last tab gone leaves for the home. With `allTabs`
* (Settings → Appearance) the tabs hold EVERY workspace's live runs, stored
* once (`all`): a leaf is a `paneKey` (lane × 10 + slot), each pane is bound
* to its own run's lane, and focusing one selects its workspace (the URL's
* `?lane=` follows `?run=`). */
function ConsoleArea({
lanes,
selectedLaneId,
@@ -189,6 +209,8 @@ function ConsoleArea({
onNewSession,
onOpenRun,
onRunsChanged,
allTabs = false,
settings = null,
}: {
lanes: Lane[];
selectedLaneId: number | null;
@@ -209,18 +231,41 @@ function ConsoleArea({
) => void;
/** A tab was opened or closed: re-read the run list now. */
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();
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
// Every workspace's runs, each `slot` rewritten to its `paneKey`, so the tab
// bar and the grid — which key everything by slot — need no other change.
const tabs = useMemo(
() =>
(activeRuns?.items ?? [])
.filter((r) => r.laneId === selectedLaneId && r.status === "running")
.filter((r) =>
allTabs
? r.laneId !== null && r.status === "running"
: r.laneId === selectedLaneId && r.status === "running"
)
.map((r) => (allTabs ? { ...r, slot: paneKey(r.laneId as number, r.slot ?? 0) } : r))
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
[activeRuns, selectedLaneId]
[activeRuns, selectedLaneId, allTabs]
);
const [groups, setGroups] = usePaneGroups(selectedLaneId);
// A run id's leaf: its slot, or with `allTabs` its `paneKey`.
const keyOf = (runId: string) => {
const l = laneOfRun(runId);
return allTabs && l !== null ? paneKey(l, slotOf(runId)) : slotOf(runId);
};
const scope: GroupsScope | null = allTabs ? "all" : selectedLaneId;
const [groups, setGroups] = usePaneGroups(scope);
// The latest tabs, updated the moment a change is made: two changes in one
// commit (an open request and a prune) must build on each other.
const groupsRef = useRef(groups);
@@ -234,7 +279,7 @@ function ConsoleArea({
// remembered layout loses its dead panes on arrival; after it a shown pane
// closes on its terminal's exit, not on the polled list — one bad read (the
// server answers an empty list on any tmux error) must not wipe a split.
const listed = useRef<number | null>(null);
const listed = useRef<GroupsScope | null>(null);
// Runs seen ending here (`id@startedAt`): a list not yet refreshed must not
// send the last pane to one of them.
const exited = useRef(new Set<string>());
@@ -245,20 +290,34 @@ function ConsoleArea({
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
const [sessionsVersion, setSessionsVersion] = useState(0);
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
// The workspaces the tabs belong to: the selected one, or with `allTabs`
// every one holding a tab.
const tabLaneIds = allTabs
? [...new Set(tabs.map((r) => r.laneId as number))].join(",")
: String(selectedLaneId ?? "");
useEffect(() => {
if (selectedLaneId === null || !tabSessionIds.replace(/,/g, "")) return;
if (!tabLaneIds || !tabSessionIds.replace(/,/g, "")) return;
let live = true;
api.sessions
.list({ lane: selectedLaneId, limit: 50 })
.then((r) => live && setTabSessions(new Map(r.sessions.map((x) => [x.id, x]))))
Promise.all(
tabLaneIds.split(",").map((id) => api.sessions.list({ lane: Number(id), limit: 50 }))
)
.then(
(rs) => live && setTabSessions(new Map(rs.flatMap((r) => r.sessions.map((x) => [x.id, x]))))
)
.catch(() => undefined);
return () => {
live = false;
};
}, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
}, [tabLaneIds, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
const laneName = (r: RunHandle) => {
const l = lanes.find((x) => x.id === r.laneId);
return l ? l.title || l.cwd.split("/").filter(Boolean).pop() || l.cwd : "";
};
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
const label = (r: RunHandle) =>
runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
const label = (r: RunHandle) => {
const name = runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
return allTabs ? `${laneName(r)} · ${name}` : name;
};
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
@@ -281,18 +340,19 @@ function ConsoleArea({
followFocus(next);
};
// Another workspace: nothing pending or exited carries over.
// Another workspace's tabs: nothing pending or exited carries over.
useEffect(() => {
pending.current.clear();
exited.current.clear();
}, [selectedLaneId]);
}, [scope]);
// An open request (a new nonce): focus, replace or split. Re-run when the
// lane arrives on a cold load; only this lane's own tabs.
useEffect(() => {
if (!attachRequest || selectedLaneId === null) return;
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return;
const slot = slotOf(attachRequest.runId);
const own = allTabs ? "\\d+" : String(selectedLaneId);
if (!new RegExp(`^agenthub-lane-${own}(-[1-9])?$`).test(attachRequest.runId)) return;
const slot = keyOf(attachRequest.runId);
// The URL catching up with a focus change: by now that pane may be closed
// or swapped, and re-opening it would undo what the user just did. Back/
// Forward onto such an entry puts the real focus back in the URL instead.
@@ -325,8 +385,8 @@ function ConsoleArea({
else if (list !== activeRuns) expired.add(s);
if (live.has(s) || list !== activeRuns) pending.current.delete(s);
}
const first = listed.current !== selectedLaneId;
listed.current = selectedLaneId;
const first = listed.current !== scope;
listed.current = scope;
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
const keep = first
? new Set(pending.current.keys())
@@ -383,14 +443,16 @@ function ConsoleArea({
const free = lane ? freeSlot(lane.id, tabs) : null;
const splitTools = lane ? newTabTools(lane, aiTools) : [];
const newSplit = async (target: number, edge: Edge, tool?: string) => {
if (!lane || free === null) return;
const laneId = lane.id;
// With `allTabs`, the split starts in the target pane's own workspace.
const into = allTabs ? lanes.find((l) => l.id === Math.floor(target / MAX_SLOTS)) : lane;
if (!into || freeSlot(into.id, tabs) === null) return;
const laneId = into.id;
try {
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
const body = tool ? newTabBody(tool, into, aiTools) : { shell: true };
// The server picks the slot: the list `free` came from may be stale.
const slot = slotOf(await startInFreeTab(laneId, body));
const slot = keyOf(await startInFreeTab(laneId, body));
onRunsChanged();
if (laneRef.current !== laneId) return;
if (!allTabs && laneRef.current !== laneId) return;
pending.current.set(slot, activeRuns);
change((l) => moveInLayout(l, slot, target, edge));
} catch (err) {
@@ -398,10 +460,10 @@ function ConsoleArea({
}
};
const requested = attachRequest ? [slotOf(attachRequest.runId)] : [];
const requested = attachRequest ? [keyOf(attachRequest.runId)] : [];
// No layout yet (a first visit, a request not yet applied, or no run at
// all): one pane, so the console always has its single render site.
const fallback = requested[0] ?? 0;
const fallback = requested[0] ?? (allTabs ? paneKey(selectedLaneId ?? 0, 0) : 0);
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
@@ -409,6 +471,13 @@ function ConsoleArea({
const change: LayoutUpdate = (fn) =>
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
const shown = leaves(view.root);
// A pane spanning the grid's full width or height has room for its chat's
// right panel to start open; a quarter-sized one starts it collapsed.
const spanning = new Set(
layoutRects(view.root)
.panes.filter((r) => r.w === 1 || r.h === 1)
.map((r) => r.slot)
);
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
const focusSlot = view.focus;
@@ -419,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;
@@ -462,12 +532,14 @@ function ConsoleArea({
onOpenRun(runId, { split, replace: true });
}}
sessions={tabSessions}
workspaceOf={allTabs ? laneName : undefined}
onRenamed={() => setSessionsVersion((v) => v + 1)}
onClosed={(runId) => ended(slotOf(runId))}
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]}
@@ -485,37 +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}
laneId={selectedLaneId}
slot={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}
solo={shown.length === 1}
attachRequest={
attachRequest && slotOf(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>
);
}
@@ -536,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.
@@ -621,6 +712,9 @@ export function Workspace() {
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Settings → Appearance: tabs from every workspace. Read on mount — the
// switch lives on another page.
const [allTabs] = useState(readAllTabs);
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
@@ -639,6 +733,22 @@ export function Workspace() {
/* private mode / disabled storage: the panel just forgets between visits */
}
}, [changesOpen]);
// The Changes panel's bottom fold (instructions, files, folders): folded by
// default so the git sections keep the height.
const [contextOpen, setContextOpen] = useState(() => {
try {
return window.localStorage.getItem(CONTEXT_OPEN_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
try {
window.localStorage.setItem(CONTEXT_OPEN_KEY, contextOpen ? "1" : "0");
} catch {
/* disabled storage: the fold just forgets between visits */
}
}, [contextOpen]);
// A preset name, or the fraction a splitter drag left (stored as a number).
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
try {
@@ -764,8 +874,13 @@ export function Workspace() {
// Right-click on a section: close extra folders — which REMOVES them from the
// workspace (the folder stays on disk). The workspace folder never closes.
const sourceCtx = useContextMenu<string>();
// Bumped after a close here, so the home's Folder list re-reads.
// Bumped after a close here or an edit in either context list (the home's,
// the Changes panel's fold), so every one on screen re-reads.
const [sourcesVersion, setSourcesVersion] = useState(0);
const sourcesChanged = (laneId: number, view: LaneSourcesView) => {
takeGitFolders(laneId, view);
setSourcesVersion((n) => n + 1);
};
const closeSources = async (laneId: number, keys: string[]) => {
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
try {
@@ -978,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;
@@ -1065,7 +1181,7 @@ export function Workspace() {
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
sourcesVersion={sourcesVersion}
onMenuAction={(action) => {
if (action === "new-here") {
@@ -1242,10 +1358,12 @@ export function Workspace() {
? () => setSearchParams({ lane: String(currentLane.id) })
: undefined
}
allTabs={allTabs}
onOpenRun={(runId, opts) =>
currentLane &&
setSearchParams(
{ lane: String(currentLane.id), run: runId },
// A run names its own workspace (another one's with `allTabs`).
{ lane: String(laneOfRun(runId) ?? currentLane.id), run: runId },
{
// Following the focus, or a tab click, is not a new
// history entry.
@@ -1259,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>
@@ -1397,6 +1540,9 @@ export function Workspace() {
<section
key={key}
data-testid="panel-git-source"
// A row dragged from an extra folder lies outside
// the pane's folder: `dragProjectPath` makes it absolute.
data-drag-root={source ? folders[i - 1]!.path : undefined}
className={`flex flex-col border-b border-border-light ${
folded ? "shrink-0" : "min-h-0 flex-1"
}`}
@@ -1462,6 +1608,37 @@ export function Workspace() {
</MenuItem>
</ContextMenu>
)}
{/* The workspace's context — the home's right column, compact. */}
<section
data-testid="panel-workspace-context"
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
contextOpen ? "max-h-[45%]" : ""
}`}
>
<button
type="button"
aria-expanded={contextOpen}
onClick={() => setContextOpen((v) => !v)}
className="flex shrink-0 items-center gap-1 py-1.5 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
{contextOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
{tLanes("home.contextPanel")}
</button>
{contextOpen && (
<div className="min-h-0 overflow-y-auto pb-2">
<WorkspaceContext
lane={currentLane}
compact
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
sourcesVersion={sourcesVersion}
/>
</div>
)}
</section>
</div>
</aside>
) : (
@@ -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"
);
});
});
@@ -4,7 +4,7 @@
* full screen, back to the last dragged width) and Collapse; the rail reopens
* it at the last dragged width, else 25/75. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order.
* lane strip's order. Its bottom fold is the workspace's context, compact.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -99,6 +99,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
removeSource: r({
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
}),
memory: r({ files: [] }),
gitTree: r({ entries: [] }),
// A file opened in the panel on a chat's request.
changes: r({ available: false }),
@@ -284,6 +285,22 @@ describe("Workspace — the Changes panel's width", () => {
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("folds the workspace context at the bottom, and a folder removed there leaves the stack", async () => {
await openPanel();
const fold = screen.getByTestId("panel-workspace-context");
const toggle = fold.querySelector("button")!;
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("workspace-context")).toBeNull();
fireEvent.click(toggle);
await settle();
expect(window.localStorage.getItem("agenthub.workspace.contextOpen")).toBe("1");
expect(screen.getByText("notes.md")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Remove shop" }));
await settle();
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("is one plain panel for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
+102 -2
View File
@@ -6,8 +6,17 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from "vitest";
import {
render,
act,
cleanup,
createEvent,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
@@ -61,6 +70,10 @@ const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
laneOfRun: (runId: string) => {
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
return m ? Number(m[1]) : null;
},
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
@@ -88,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>
@@ -287,6 +305,10 @@ vi.mock("../../lib/eventBus", () => ({
},
}));
vi.mock("../../components/run/PaneChat", () => ({
PaneChat: () => <div data-testid="pane-chat" />,
}));
vi.mock("../../components/run/TerminalView", () => ({
// `exit-<runId>` stands in for the server's exit frame.
TerminalView: ({
@@ -310,6 +332,7 @@ vi.mock("../../components/run/TerminalView", () => ({
import { Workspace } from "../Workspace";
import { api } from "../../lib/api";
import { setSessionChat } from "../../lib/chatUi";
class ObserverStub {
observe() {}
@@ -611,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");
@@ -630,6 +675,30 @@ describe("console tabs", () => {
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
it("with tabs from every workspace on, shows another workspace's session and selects it on click", async () => {
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
function LaneParam() {
const [params] = useSearchParams();
return <span data-testid="lane-param">{params.get("lane")}</span>;
}
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
<LaneParam />
</MemoryRouter>
);
await settle();
await settle();
// Leaves are `paneKey`s: lane × 10 + slot.
expect(screen.getByTestId("console-tab-10")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-21")).toHaveTextContent("Lane B");
await userEvent.setup().click(screen.getByTestId("console-tab-21"));
await waitFor(() => expect(screen.getByTestId("lane-param")).toHaveTextContent("2"));
expect(screen.getByTestId("console-tab-21")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
});
it("closing the active tab stops its session and opens the next one", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
@@ -706,6 +775,37 @@ describe("split panes", () => {
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
});
it("keeps each session's chat view when two tabs join into one combo", async () => {
setSessionChat("sess-0", true);
setSessionChat("sess-1", true);
const items = [
{ ...live(0), sessionId: "sess-0" },
{ ...live(1), sessionId: "sess-1" },
];
withRuns(items as never);
// As the server does: GET /api/run/:id is the list's own handle.
const get = vi.mocked(api.run.get).getMockImplementation()!;
onTestFinished(() => void vi.mocked(api.run.get).mockImplementation(get));
vi.mocked(api.run.get).mockImplementation(
async (id) => items.find((r) => r.id === id) as never
);
await open("/workspace?lane=1&run=agenthub-lane-1");
// Tab b dragged onto the middle of tab a (jsdom: every rect is 0 wide).
const drop = createEvent.drop(screen.getByTestId("console-tab-0"), {
dataTransfer: {
types: ["application/x-agenthub-tab"],
getData: (t: string) => (t === "application/x-agenthub-tab" ? "1" : ""),
},
});
Object.defineProperty(drop, "clientX", { value: 0 });
fireEvent(screen.getByTestId("console-tab-0"), drop);
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
await settle();
for (const s of [0, 1])
expect(within(screen.getByTestId(`pane-${s}`)).getByTestId("pane-chat")).toBeInTheDocument();
});
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
withRuns([live(0), live(1), live(2)]);
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.6.1 (rebuild client to match)
UI build v2.7.2 (rebuild client to match)
</span>
</p>
</div>
+4
View File
@@ -116,6 +116,10 @@ export default {
fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
// Tailwind's stock stack minus Georgia: Windows picks Georgia, which has
// no precomposed Vietnamese glyphs, so Chrome splits "ế" into "ê" + a
// tone mark it cannot place and the chat reads "Chuyê ́n".
serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"],
},
animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
+22 -4
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.
@@ -1386,7 +1388,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)
@@ -1571,7 +1573,7 @@ curl http://localhost:4820/api/sessions/sess_abc123
GET /api/sessions/:id/outline
```
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `<data dir>/drops/` dir — `agenthub-drops-<uid>` in older transcripts — that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `<data dir>/drops/` dir — `agenthub-drops-<uid>` in older transcripts — that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
```bash
curl http://localhost:4820/api/sessions/sess_abc123/outline
@@ -1591,6 +1593,20 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
---
#### Get an Image the Session Read
```http
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.
```bash
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
```
---
#### Get Session Stats
```http
@@ -2580,10 +2596,12 @@ GET /api/run/tmux { available: bool } — whether tmux is on
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50
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 served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); 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)
@@ -2604,7 +2622,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is flipped to `abandoned` once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is ended once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. It becomes `interrupted` when an agent session (not a Terminal tab, `session_id` known) died under the user — a reboot, a killed tmux, a crash; a Claude session the user ended (`SessionEnd` with reason `prompt_input_exit`/`logout`) and every row with no session become `abandoned`. Only Claude reports why it ended, so any other tool's vanished pane counts as interrupted. A pane the dashboard closed (Stop, close tab, Quit) is already `killed`. `GET /api/run/interrupted` lists the newest interrupted row of each session no newer run holds (`{id, session_id, lane_id, tool, cwd, model, permission_mode, effort, prompt_preview, started_at, ended_at, lane_title, lane_cwd, session_name}`), only the caller's own workspaces in server mode — the app-wide **Resume interrupted sessions?** dialog reads it; dismissing flips them to `abandoned`. The heal beat broadcasts `runs_interrupted` (no payload) whenever it ended a row, and every client refetches. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
---
+7 -4
View File
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -797,8 +797,11 @@ 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`).
### Undo and uninstall
@@ -847,7 +850,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.6.0
version: 2.7.2
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.6.1",
"version": "2.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.6.1",
"version": "2.7.2",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.6.1",
"version": "2.7.2",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -55,7 +55,7 @@
"dev:client": "cd client && npm run dev",
"build": "cd client && npm run build",
"start": "node server/index.js",
"setup": "npm install && (cd client && npm install) && npm run link-cli",
"setup": "npm install && npm --prefix client install && npm run link-cli",
"update:pull-setup": "git pull --ff-only && npm run setup",
"install-hooks": "node scripts/install-hooks.js",
"seed": "node scripts/seed.js",
+2 -1
View File
@@ -155,9 +155,10 @@ function reloadPanes() {
timeout: 15_000,
stdio: ["ignore", "pipe", "pipe"],
});
// psmux (Windows) ignores -F: `name: 1 windows (…)`; a name holds no `:`.
names = out
.split("\n")
.map((s) => s.trim())
.map((s) => s.split(":")[0].trim())
.filter((s) => s.startsWith("agenthub-lane-"));
} catch {
// No tmux server at all means no open sessions — nothing to reload, which
+36
View File
@@ -3120,3 +3120,39 @@ describe("GET /api/sessions/:id/outline", () => {
assert.deepEqual(body.outputs, ["/w/src/b.ts", "/w/src/a.ts"]);
});
});
describe("GET /api/sessions/:id/image", () => {
it("serves only an image the session itself read", async () => {
const sid = "read-image-" + Date.now();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-read-image-"));
const png = path.join(dir, "shot.png");
const other = path.join(dir, "other.png");
const txt = path.join(dir, "notes.txt");
fs.writeFileSync(png, "PNGBYTES");
fs.writeFileSync(other, "OTHER");
fs.writeFileSync(txt, "text");
const read = (file_path) =>
post("/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: sid, tool_name: "Read", tool_input: { file_path }, tool_response: {} },
});
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
await read(png);
await read(txt);
const url = (p) => `/api/sessions/${sid}/image?path=${encodeURIComponent(p)}`;
const ok = await fetch(url(png));
assert.equal(ok.status, 200);
assert.equal(ok.headers["content-type"], "image/png");
assert.equal(ok.headers["x-content-type-options"], "nosniff");
assert.match(ok.headers["content-security-policy"], /sandbox/);
assert.equal(ok.body, "PNGBYTES");
// Never read by this session, not an image, not a session.
assert.equal((await fetch(url(other))).status, 404);
assert.equal((await fetch(url(txt))).status, 400);
assert.equal((await fetch(`/api/sessions/nope-image/image?path=${png}`)).status, 404);
fs.rmSync(dir, { recursive: true, force: true });
assert.equal((await fetch(url(png))).status, 404);
});
});
+47
View File
@@ -86,6 +86,53 @@ describe("dashboard-runs", () => {
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
});
it("offers for resume only the sessions whose pane died under them", () => {
const { db } = require("../db");
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (20, 'w', '/i')").run();
const session = db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, ?, 'active', '/i')"
);
for (const id of ["s-boot", "s-exit", "s-signal", "s-codex"]) session.run(id, id);
const end = db.prepare(
"INSERT INTO events (session_id, event_type, data) VALUES (?, 'SessionEnd', ?)"
);
end.run("s-exit", JSON.stringify({ reason: "prompt_input_exit" }));
end.run("s-signal", JSON.stringify({ reason: "other" }));
start("agenthub-lane-20", { laneId: 20, sessionId: "s-boot" });
start("agenthub-lane-20-1", { laneId: 20, sessionId: "s-exit" });
start("agenthub-lane-20-2", { laneId: 20, sessionId: "s-signal" });
start("agenthub-lane-20-3", { laneId: 20, sessionId: "s-codex", tool: "codex" });
start("agenthub-lane-20-4", { laneId: 20, tool: "shell" });
start("agenthub-lane-20-5", { laneId: 20 }); // no session known: nothing to resume
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-20"));
const status = (id) => runs.getRun(id).status;
assert.equal(status("agenthub-lane-20"), "interrupted");
assert.equal(status("agenthub-lane-20-1"), "abandoned", "the user's own /exit");
assert.equal(status("agenthub-lane-20-2"), "interrupted", "a signal is not the user");
assert.equal(status("agenthub-lane-20-3"), "interrupted");
assert.equal(status("agenthub-lane-20-5"), "abandoned");
const offered = () =>
runs
.listInterrupted()
.map((r) => r.session_id)
.sort();
assert.deepEqual(offered(), ["s-boot", "s-codex", "s-signal"]);
assert.deepEqual(runs.listInterrupted(99), [], "another owner's workspace");
// Resumed by any path: a newer run of the session takes it off the list.
start("agenthub-lane-20-6", {
laneId: 20,
sessionId: "s-signal",
startedAt: Date.now() + 1000,
});
assert.deepEqual(offered(), ["s-boot", "s-codex"]);
assert.equal(runs.dismissInterrupted(["s-boot"], 99), 0, "not the caller's");
assert.equal(runs.dismissInterrupted(["s-boot", "s-codex"]), 2);
assert.deepEqual(offered(), []);
});
it("gives a hookless tool's session the lane of the run it started in", () => {
const { db } = require("../db");
start("agenthub-lane-7-1", {
+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: [],
+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);
+42
View File
@@ -227,4 +227,46 @@ describe("attachBinary on Windows", () => {
process.env.PATH = saved;
}
});
it("attaches through node-pty's bundled conpty.dll on Windows only", () => {
// The inbox ConPTY re-renders psmux's output and leaves stray cells
// (torn left edge, bars mid-screen) wherever its glyph widths disagree
// with xterm.js's.
for (const [platform, dll] of [
["win32", true],
["linux", undefined],
]) {
let opts;
ptyAttach.__setSpawnImpl((_f, _a, o) => {
opts = o;
return makeFakePty();
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24, platform });
assert.equal(opts.useConptyDll, dll);
ptyAttach.__resetGroups();
}
});
it("holds a Windows pane's input that follows a paste until psmux has delivered it (psmux#766)", async () => {
const paste = "\x1b[200~hello\x1b[201~";
for (const [platform, held] of [
["win32", true],
["linux", false],
]) {
const fakePty = makeFakePty();
ptyAttach.__setSpawnImpl(() => fakePty);
tmux.__setExecImpl(() => "");
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform });
ws.__emitter.emit("message", Buffer.from(paste), false);
ws.__emitter.emit("message", Buffer.from("\r"), false);
assert.deepEqual(fakePty.__writes, held ? [paste] : [paste, "\r"], platform);
if (held) {
await new Promise((r) => setTimeout(r, 800));
assert.deepEqual(fakePty.__writes, [paste, "\r"]);
}
ptyAttach.__resetGroups();
}
tmux.__reset();
});
});
+9
View File
@@ -526,6 +526,15 @@ describe("pty-run", () => {
assert.match(pty.paneArgv(null, "/w", "linux")[2], /exec "\$SHELL" -l$/);
});
it("keeps a Windows claude's teammates in-process — psmux cannot split a pane for them", () => {
const win = pty.buildClaudeArgv({ platform: "win32" });
assert.deepEqual(
win.slice(win.indexOf("--teammate-mode"), win.indexOf("--teammate-mode") + 2),
["--teammate-mode", "in-process"]
);
assert.ok(!pty.buildClaudeArgv({ platform: "linux" }).includes("--teammate-mode"));
});
it("starts a pane in its folder even where tmux skips -c (issue #69)", () => {
// A tmux server whose own cwd was deleted (a pruned plugin cache dir)
// ignores `-c` and hands every pane that deleted cwd.
+42 -1
View File
@@ -321,7 +321,20 @@ describe("GET /api/run/drop/:name", () => {
fs.unlinkSync(saved.body.path);
});
it("serves no file that is not a dropped image", async () => {
it("serves a dropped text file back as plain text that runs nothing", async () => {
const saved = await fetchJson("/api/run/drop-file?name=pasted-text.txt", {
method: "POST",
raw: { type: "text/plain", buf: Buffer.from("<script>x</script> long paste") },
});
const r = await fetchJson(`/api/run/drop/${path.basename(saved.body.path)}`);
assert.equal(r.status, 200);
assert.equal(r.headers["content-type"], "text/plain; charset=utf-8");
assert.equal(r.headers["x-content-type-options"], "nosniff");
assert.match(r.headers["content-security-policy"], /sandbox/);
fs.unlinkSync(saved.body.path);
});
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}`,
@@ -447,3 +460,31 @@ describe("/api/run/:id/permission-mode", () => {
assert.equal(r.status, 404);
});
});
describe("interrupted sessions", () => {
let srv;
before(async () => {
srv = http.createServer(createApp());
await new Promise((r) => srv.listen(0, r));
BASE = `http://127.0.0.1:${srv.address().port}`;
});
after(() => new Promise((r) => srv.close(r)));
it("lists and dismisses, refusing a body that is not a list of ids", async () => {
const list = await fetchJson("/api/run/interrupted");
assert.equal(list.status, 200);
assert.ok(Array.isArray(list.body.items));
const bad = await fetchJson("/api/run/interrupted/dismiss", {
method: "POST",
body: { sessionIds: "s-1" },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
const ok = await fetchJson("/api/run/interrupted/dismiss", {
method: "POST",
body: { sessionIds: ["s-none"] },
});
assert.equal(ok.status, 200);
assert.equal(ok.body.dismissed, 0);
});
});
@@ -0,0 +1,60 @@
/**
* @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}` });
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);
});
});
+13
View File
@@ -38,6 +38,19 @@ describe("tmux wrapper", () => {
assert.deepEqual(calls, ["list-sessions"]);
});
it("reads psmux's list-sessions, which ignores -F, as bare names (Windows)", () => {
// psmux prints its default line whatever `-F` asks for; read as names,
// every pane looked dead and the heal beat closed its run within seconds.
tmux.__setExecImpl((args) =>
args[0] === "list-sessions"
? "agenthub-lane-2-5: 1 windows (created Sat Oct 10 11:44:39 2026)\r\n" +
"agenthub-lane-2: 1 windows (created Sat Oct 10 10:43:16 2026)\r\n"
: ""
);
assert.deepEqual([...tmux.liveSessions()], ["agenthub-lane-2-5", "agenthub-lane-2"]);
assert.deepEqual(tmux.listSessions("agenthub-lane-"), ["agenthub-lane-2-5", "agenthub-lane-2"]);
});
it("liveSessions is empty when tmux answers that no server runs (or is not installed)", () => {
tmux.__setExecImpl(() => {
throw Object.assign(new Error("no server running"), { status: 1 });
@@ -0,0 +1,78 @@
/**
* @file GET /api/sessions/:id/transcript keeps a long conversation turn whole:
* the chat shows an assistant's answer and a user's prompt as they were, while
* a tool's output — the part that can be megabytes — is still capped.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-long-text-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const ID = "5a1e0c2e-0000-4000-8000-000000000001";
const CWD = "/w/long";
const LONG = "Câu hỏi dài. ".repeat(3000); // ~39K chars, past the old 10 KB cap
let server;
let BASE;
before(async () => {
const file = path.join(
process.env.CLAUDE_HOME,
"projects",
CWD.replace(/[^a-zA-Z0-9]/g, "-"),
`${ID}.jsonl`
);
fs.mkdirSync(path.dirname(file), { recursive: true });
const lines = [
{ type: "user", message: { role: "user", content: LONG } },
{
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "text", text: LONG },
{ type: "tool_use", id: "t1", name: "Bash", input: { command: "cat big" } },
],
},
},
{
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "t1", content: LONG }],
},
},
];
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
db.prepare("INSERT INTO sessions (id, status, cwd) VALUES (?, 'completed', ?)").run(ID, CWD);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
try {
db.close();
} catch {}
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("transcript long text", () => {
it("keeps a long prompt and answer whole and still caps a tool's output", async () => {
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
const [prompt, answer, result] = body.messages;
assert.equal(prompt.content[0].text, LONG);
assert.equal(answer.content[0].text, LONG);
assert.ok(result.content[0].output.endsWith("[truncated]"));
});
});
+47 -1
View File
@@ -12,7 +12,7 @@ const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { getUpdatesStatus, npmInvocation } = require("../lib/update-check");
const { getUpdatesStatus, npmInvocation, execNpm } = require("../lib/update-check");
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
@@ -199,3 +199,49 @@ describe("npmInvocation (the checkout's Update now)", () => {
assert.deepEqual(npm.args.slice(-2), ["run", "build"]);
});
});
describe("npm run setup (the checkout's Update now)", () => {
it("never changes directory: cmd.exe's ( ) is no subshell, so a `cd` leaks into the next step", () => {
// On Windows `(cd client && npm install) && npm run link-cli` ran
// link-cli inside client/ — "Missing script" — and Update now failed.
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
assert.doesNotMatch(pkg.scripts.setup, /\bcd\b/);
assert.match(pkg.scripts.setup, /npm --prefix client install/);
});
});
describe("execNpm (the checkout's Update now)", () => {
let dir;
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "exec-npm-"));
const noise = "for (let i = 0; i < 60; i++) console.error('npm warn noise ' + i);";
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({
name: "fx",
version: "1.0.0",
scripts: {
fail: `node -e "${noise} console.error('ERR the real cause'); process.exit(1)"`,
slow: `node -e "setTimeout(() => {}, 60000)"`,
},
})
);
});
after(() => fs.rmSync(dir, { recursive: true, force: true }));
it("names the failed step and keeps the end of npm's output, where the error is", async () => {
await assert.rejects(execNpm(dir, ["run", "fail"]), (err) => {
assert.match(err.message, /^npm run fail failed/);
assert.match(err.message, /ERR the real cause/);
assert.doesNotMatch(err.message, /noise 0\b/);
return true;
});
});
it("says a step was stopped at the time limit instead of echoing its warnings", async () => {
await assert.rejects(execNpm(dir, ["run", "slow"], { timeout: 3000 }), (err) => {
assert.match(err.message, /npm run slow did not finish within/);
return true;
});
});
});
+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
+4 -3
View File
@@ -763,8 +763,9 @@ function startBackgroundServices() {
const live = liveSessions();
if (!live) return; // tmux could not be asked: skip, never end every run
healAllRunIds(live);
// A pane that quit (Ctrl+C, `exit`) ends its run record too.
reconcileOrphans((id) => live.has(id));
// A pane that quit (Ctrl+C, `exit`) ends its run record too; one that
// died under its agent (killed tmux, crash) is offered for resume.
if (reconcileOrphans((id) => live.has(id)) > 0) broadcast("runs_interrupted", {});
} catch (err) {
console.warn("run heal failed:", err.message);
}
@@ -814,7 +815,7 @@ function startBackgroundServices() {
const { hasSession } = require("./lib/tmux");
const reconciled = reconcileOrphans(hasSession);
if (reconciled > 0) {
console.log(`[runs] reconciled ${reconciled} orphan run(s) → abandoned`);
console.log(`[runs] reconciled ${reconciled} orphan run(s) → interrupted/abandoned`);
}
} catch (err) {
console.warn("dashboard-runs reconciliation failed:", err.message);
+82 -13
View File
@@ -19,7 +19,9 @@
* under `<id>@<its started_at>` (flagged `abandoned` if it never said it
* ended) — history is kept, and `getRun(<pane name>)` is always the pane's
* current run. A row is flagged dead only once its tmux session is gone
* (`reconcileOrphans(isAlive)`): panes outlive a server restart.
* (`reconcileOrphans(isAlive)`): panes outlive a server restart. A session
* whose pane died under it is kept `interrupted` until the user resumes or
* dismisses it (`listInterrupted`, `dismissInterrupted`).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -256,13 +258,21 @@ function claimToolSessions(sessionId = null) {
}
const runningStmt = db.prepare(
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
"SELECT id, tool, session_id, cwd FROM dashboard_runs WHERE status IN ('running', 'spawning')"
);
const abandonStmt = db.prepare(`
const endStmt = db.prepare(`
UPDATE dashboard_runs
SET status = 'abandoned',
SET status = @status,
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
WHERE id = ? AND status IN ('running', 'spawning')
WHERE id = @id AND status IN ('running', 'spawning')
`);
// Claude says why it ended: `/exit`, Ctrl+C/Ctrl+D and `/logout` are the
// user's own; a signal (tmux killed) reports `other` and a power cut nothing.
const userExitStmt = db.prepare(`
SELECT 1 FROM events
WHERE session_id = ? AND event_type = 'SessionEnd'
AND json_extract(data, '$.reason') IN ('prompt_input_exit', 'logout')
LIMIT 1
`);
// A shell has no session, so its row only tracks the open tab: once closed it
@@ -272,20 +282,33 @@ const pruneShellsStmt = db.prepare(`
`);
/**
* Flags `abandoned` every row still marked running/spawning whose pane is
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
* A pane is a tmux session, which outlives a server restart, so liveness is
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
* and on the server's run-heal beat, which also drops every closed Terminal
* tab's row.
* Ends every row still marked running/spawning whose pane is gone. A pane is
* a tmux session, which outlives a server restart, so liveness is asked of
* `isAlive(id)` (tmux), never assumed from the restart. Run at boot and on
* the server's run-heal beat, which also drops every closed Terminal tab's row.
*
* A pane AgentHub closed itself (Stop, close tab, Quit) is already `killed`.
* Of the rest, an agent session the user did not exit is `interrupted` — a
* reboot, a killed tmux, a crash — and offered for resume
* (`listInterrupted`); everything else is `abandoned`. Only Claude reports
* how it ended, so any other tool's vanished pane counts as interrupted.
*
* Returns the number of rows updated.
*/
function reconcileOrphans(isAlive = () => false) {
try {
let changes = 0;
for (const { id } of runningStmt.all()) {
if (!isAlive(id)) changes += abandonStmt.run(id).changes;
const { unseenMemberFolder } = require("./linux-accounts");
for (const row of runningStmt.all()) {
if (isAlive(row.id) || unseenMemberFolder(row.cwd)) continue;
const interrupted =
row.tool !== "shell" &&
!!row.session_id &&
!(row.tool === "claude" && userExitStmt.get(row.session_id));
changes += endStmt.run({
id: row.id,
status: interrupted ? "interrupted" : "abandoned",
}).changes;
}
pruneShellsStmt.run();
return changes;
@@ -294,6 +317,50 @@ function reconcileOrphans(isAlive = () => false) {
}
}
// The newest interrupted run of each session that nothing ran since — a
// session resumed by any path drops out — of workspaces `@owner` owns (any
// owner when null). A workspace removed since takes its rows with it.
const interruptedStmt = db.prepare(`
SELECT r.id, r.session_id, r.lane_id, r.tool, r.cwd, r.model, r.permission_mode, r.effort,
r.prompt_preview, r.started_at, r.ended_at,
l.title AS lane_title, l.cwd AS lane_cwd, s.name AS session_name
FROM dashboard_runs r
JOIN lanes l ON l.id = r.lane_id
LEFT JOIN sessions s ON s.id = r.session_id
WHERE r.status = 'interrupted' AND r.session_id IS NOT NULL
AND (@owner IS NULL OR l.owner_id = @owner)
AND NOT EXISTS (
SELECT 1 FROM dashboard_runs n
WHERE n.session_id = r.session_id AND n.id != r.id
AND (n.started_at >= r.started_at OR n.status IN ('running', 'spawning'))
)
ORDER BY r.ended_at DESC
LIMIT 50
`);
const dismissStmt = db.prepare(`
UPDATE dashboard_runs SET status = 'abandoned'
WHERE status = 'interrupted' AND session_id = @sessionId
AND lane_id IN (SELECT id FROM lanes WHERE @owner IS NULL OR owner_id = @owner)
`);
/** Sessions whose pane went away without the user ending them, newest first. */
function listInterrupted(ownerId = null) {
try {
return interruptedStmt.all({ owner: ownerId });
} catch {
return [];
}
}
/** The user answered: these sessions are no longer offered for resume. */
function dismissInterrupted(sessionIds, ownerId = null) {
let changes = 0;
for (const sessionId of sessionIds) {
changes += dismissStmt.run({ sessionId, owner: ownerId }).changes;
}
return changes;
}
module.exports = {
recordRun,
patchRun,
@@ -301,6 +368,8 @@ module.exports = {
getRun,
forgetShellRun,
reconcileOrphans,
listInterrupted,
dismissInterrupted,
proxyForSession,
laneForSession,
claimToolSessions,
+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,
+41 -4
View File
@@ -65,6 +65,14 @@ function attachBinary(platform = process.platform) {
}
}
// psmux (Windows) holds a bracketed paste ~350 ms before the pane sees it,
// and input written meanwhile lands INSIDE it, before ESC[201~ (psmux#766):
// the Chat view's Enter, 100 ms after its paste, became a pasted newline and
// the prompt sat in the composer as "[Pasted text #1]". Input that follows a
// paste therefore waits until this long after it; 500 ms already arrived after
// the paste in that issue's measurements.
const PASTE_HOLD_MS = 700;
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
const groups = new Map();
@@ -91,7 +99,7 @@ function drive(group, v) {
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t <runId>` per call.
*/
function attach(ws, runId, { cols, rows }) {
function attach(ws, runId, { cols, rows, platform = process.platform }) {
validateRunId(runId);
// Retroactively covers sessions started before mouse mode was wired into
// newSession (or before a server restart picked that change up) — see
@@ -117,6 +125,12 @@ function attach(ws, runId, { cols, rows }) {
name: "xterm-256color",
cols: lead ? lead.cols : cols || 80,
rows: lead ? lead.rows : rows || 24,
// Windows' inbox ConPTY re-renders psmux's output instead of passing it
// through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻,
// Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen.
// The conpty.dll + OpenConsole.exe in @lydell/node-pty-win32-* is the
// newer one VS Code uses for the same reason. Ignored elsewhere.
...(platform === "win32" && { useConptyDll: true }),
});
const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
group.viewers.add(me);
@@ -146,6 +160,29 @@ function attach(ws, runId, { cols, rows }) {
}
});
// Every keystroke goes through here, in order; see PASTE_HOLD_MS.
const holdsPaste = platform === "win32";
let holdUntil = 0;
let held = [];
const flush = () => {
const q = held;
held = [];
try {
q.forEach(write);
} catch {
/* the attach exited while its input was held — nothing to write to */
}
};
function write(text) {
const wait = holdUntil - Date.now();
if (held.length || wait > 0) {
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
return;
}
pty.write(text);
if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS;
}
pty.onExit(({ exitCode }) => {
try {
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
@@ -167,7 +204,7 @@ function attach(ws, runId, { cols, rows }) {
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
const text = data.toString("utf8");
if (binary) {
pty.write(text);
write(text);
return;
}
// The browser's WebSocket API sends a JS string as a text frame, and
@@ -181,7 +218,7 @@ function attach(ws, runId, { cols, rows }) {
try {
msg = JSON.parse(text);
} catch {
pty.write(text);
write(text);
return;
}
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
@@ -193,7 +230,7 @@ function attach(ws, runId, { cols, rows }) {
return;
}
}
pty.write(text);
write(text);
});
ws.on("close", () => {
+11
View File
@@ -177,6 +177,7 @@ function buildClaudeArgv({
addDirs = [],
systemPromptFile = null,
proxyArgv = [],
platform = process.platform,
}) {
const argv = ["claude"];
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
@@ -184,6 +185,15 @@ function buildClaudeArgv({
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// Inside a pane Claude Code sees $TMUX and opens agent-team teammates with
// `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads
// back is wrong and the follow-up respawn-pane/set-option land on the
// leader's own pane — the session is replaced by a bare cmd.exe. psmux also
// answers a target that does not exist (`list-panes -t <s>:claude-swarm`)
// with the CURRENT pane instead of an error, so any teammate path can hit
// the leader: Claude 2.1.296 dies on a named Agent call (older builds only
// failed with "Could not determine pane count for swarm window").
if (platform === "win32") argv.push("--teammate-mode", "in-process");
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
argv.push(...proxyArgv);
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
@@ -767,4 +777,5 @@ module.exports = {
MAX_PANE_SLOT,
SHELL_COMMANDS,
paneArgv,
buildClaudeArgv,
};
+2
View File
@@ -267,6 +267,8 @@ function broadcastAudience(type, data) {
case "lane_runtime":
return { owners: laneOwners(d.laneId) };
case "update_status":
// No payload: each client refetches its own `GET /api/run/interrupted`.
case "runs_interrupted":
return null;
// A member's prompt box refetches its command list on these, but the
// payload names the host's profiles and files: members get the type only.
+12 -3
View File
@@ -196,6 +196,15 @@ function __reset() {
// too and is shared; any other failure is not.
const LIST_TTL_MS = 1000;
const LIST_ARGS = ["list-sessions", "-F", "#{session_name}"];
// psmux (Windows) ignores `-F` and prints `name: 1 windows (created …)`. A
// session name never holds a `:` (tmux rewrites it), so the text before the
// first one is the name in either form.
const sessionNames = (out) =>
String(out)
.split("\n")
.map((l) => l.split(":")[0].trim())
.filter(Boolean)
.join("\n");
let listed = new Map(); // member login ("" = ours) → { at, out } | { at, err }
let listGen = 0; // bumped by a change this process makes: drops lists in flight
function listOutput(acct = null) {
@@ -206,7 +215,7 @@ function listOutput(acct = null) {
return hit.out;
}
try {
const out = String(execImpl(LIST_ARGS));
const out = sessionNames(execImpl(LIST_ARGS));
listed.set("", { at: Date.now(), out });
return out;
} catch (err) {
@@ -252,7 +261,7 @@ function refreshList(acct) {
// about another pane) is not believed.
(out) =>
done({
out: String(out)
out: sessionNames(out)
.split("\n")
.filter((n) => ownedBy(n.trim(), acct))
.join("\n"),
@@ -308,7 +317,7 @@ function exact() {
if (exactTargets !== null) return exactTargets;
let first;
try {
first = String(execImpl(["list-sessions", "-F", "#{session_name}"]))
first = sessionNames(execImpl(LIST_ARGS))
.split("\n")
.map((x) => x.trim())
.find(Boolean);
+18 -4
View File
@@ -94,19 +94,32 @@ function npmInvocation(args, env = process.env) {
return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest });
}
// `npm run setup` installs the client's dependencies more than once and
// `npm run build` type-checks before bundling; on Windows (Defender scanning
// every file) each step ran past the old 5-minute cap.
const NPM_TIMEOUT_MS = 20 * 60_000;
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
* {@link applyUpdate} runs after fast-forwarding. */
* {@link applyUpdate} runs after fast-forwarding. A failure names its step and
* carries the END of npm's output — the error, not the warnings before it. */
function execNpm(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 300_000;
const timeout = opts.timeout ?? NPM_TIMEOUT_MS;
const npm = npmInvocation(args);
const step = `npm ${args.join(" ")}`;
return new Promise((resolve, reject) => {
execFile(
npm.file,
npm.args,
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell },
(err, stdout, stderr) => {
if (err) reject(new Error(stderr || err.message || String(err)));
else resolve(String(stdout).trim());
if (!err) return resolve(String(stdout).trim());
if (err.killed) {
return reject(
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
);
}
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
reject(new Error(`${step} failed:\n${tail}`));
}
);
});
@@ -880,4 +893,5 @@ module.exports = {
repoUrlFor,
isShallowClone,
npmInvocation,
execNpm,
};
+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"],
+3
View File
@@ -123,6 +123,9 @@ 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;
} else {
+31
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`).
*
@@ -39,6 +43,7 @@ const PREF_KEYS = [
"agenthub.permissionMode",
"agenthub.tour",
"agenthub.chatUi",
"agenthub.workspace.allTabs",
];
const MAX_VALUE = 8 * 1024;
@@ -104,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);
+42 -4
View File
@@ -226,6 +226,29 @@ router.get("/history", (req, res) => {
});
});
/**
* Sessions whose pane went away without the user ending them (a reboot, a
* killed tmux, a crash), for the "resume interrupted sessions" prompt. Only
* the caller's own workspaces in server mode: an admin is not offered a
* developer's sessions.
*/
router.get("/interrupted", (req, res) => {
const dr = require("../lib/dashboard-runs");
res.json({ items: dr.listInterrupted(scope.ownerFor(req)) });
});
/** The user answered the prompt: stop offering these sessions. Resumes nothing. */
router.post("/interrupted/dismiss", (req, res) => {
const ids = req.body?.sessionIds;
if (!Array.isArray(ids) || ids.length > 100 || !ids.every((id) => typeof id === "string")) {
return res.status(400).json({
error: { code: "EBADFIELD", message: "sessionIds must be an array of session ids" },
});
}
const dr = require("../lib/dashboard-runs");
res.json({ dismissed: dr.dismissInterrupted(ids, scope.ownerFor(req)) });
});
/**
* Forget one closed Terminal tab (`tool = 'shell'`) from the history at once,
* ahead of the run-heal beat. A live pane is refused; another tool's row is a 404.
@@ -475,9 +498,10 @@ router.post("/", (req, res) => {
}
});
// A dropped image, shown back in the session's conversation. Only a name
// drop-image/drop-file made (random hex prefix, no separator) and only an image
// type; raster only and under a sandbox CSP, like `/git/raw`.
// A dropped image, shown back in the session's conversation, or a dropped text
// file (a long paste, #68), previewed in its Uploads/Files read list. Only a name
// drop-image/drop-file made (random hex prefix, no separator) and only those
// types; raster images, text as text/plain, under a sandbox CSP, like `/git/raw`.
const DROP_IMAGE_TYPES = {
png: "image/png",
jpg: "image/jpeg",
@@ -485,10 +509,24 @@ const DROP_IMAGE_TYPES = {
gif: "image/gif",
webp: "image/webp",
};
const DROP_TEXT_TYPES = new Set([
"txt",
"md",
"markdown",
"csv",
"tsv",
"log",
"json",
"xml",
"yaml",
"yml",
]);
router.get("/drop/:name", (req, res) => {
const name = String(req.params.name);
const m = /^[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.([A-Za-z]+)$/.exec(name);
const type = m && DROP_IMAGE_TYPES[m[1].toLowerCase()];
const ext = m && m[1].toLowerCase();
const type =
ext && (DROP_IMAGE_TYPES[ext] || (DROP_TEXT_TYPES.has(ext) && "text/plain; charset=utf-8"));
// Opened first, then checked on the descriptor (no window to swap the name
// 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.
+71 -7
View File
@@ -11,6 +11,7 @@ const { broadcast } = require("../websocket");
const { calculateCost, attachAgentCosts } = require("./pricing");
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
const { memberId, sessionWhere, canSeeSession } = require("../lib/scope");
const { isServerMode } = require("../lib/auth");
const {
projectsDirFor,
sessionProjectsDirs,
@@ -475,6 +476,61 @@ router.get("/:id/outline", (req, res) => {
res.json(sessionOutline(req.params.id));
});
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
// 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.
const READ_IMAGE_TYPES = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
};
const READ_IMAGE_MAX = 20 * 1024 * 1024;
const sessionReadFile = db.prepare(
`SELECT 1 FROM events
WHERE session_id = ? AND event_type = 'PostToolUse' AND tool_name = 'Read'
AND json_extract(data, '$.tool_input.file_path') = ?
LIMIT 1`
);
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:")) {
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");
if (!sessionReadFile.get(session.id, file)) {
return fail(404, "ENOTFOUND", "the session never read that image");
}
let fh;
try {
// 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");
if (st.size > READ_IMAGE_MAX) return fail(400, "ETOOBIG", "image is larger than 20 MB");
const buf = await fh.readFile();
res.set({
"Content-Type": type,
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
});
res.send(buf);
} catch {
if (!res.headersSent) fail(404, "ENOTFOUND", "no such image");
} finally {
await fh?.close();
}
});
/** The `/:id/stats` body — also frozen into a session snapshot. */
function sessionStats(sessionId) {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
@@ -913,7 +969,7 @@ router.get("/:id/transcript", async (req, res) => {
type: "user",
sender: "user", // local slash-command I/O is the human's own action
timestamp: entry.timestamp || null,
content: [{ type: "text", text: truncate(sysText, 10240) }],
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
line: num,
};
}
@@ -942,7 +998,7 @@ router.get("/:id/transcript", async (req, res) => {
type: "user",
sender: isSystem ? "system" : "user",
timestamp: entry.timestamp || att.timestamp || null,
content: [{ type: "text", text: truncate(prompt, 10240) }],
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
line: num,
};
}
@@ -953,11 +1009,11 @@ router.get("/:id/transcript", async (req, res) => {
if (entry.type === "user") {
const msgContent = entry.message?.content;
if (typeof msgContent === "string") {
content.push({ type: "text", text: truncate(msgContent, 10240) });
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
} else if (Array.isArray(msgContent)) {
for (const block of msgContent) {
if (block.type === "text" && block.text) {
content.push({ type: "text", text: truncate(block.text, 10240) });
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
} else if (block.type === "tool_result") {
// A line carries one tool result; an edit's +/- counts ride on it.
const diff = patchCounts(entry.toolUseResult);
@@ -983,7 +1039,7 @@ router.get("/:id/transcript", async (req, res) => {
if (Array.isArray(msgContent)) {
for (const block of msgContent) {
if (block.type === "text" && block.text) {
content.push({ type: "text", text: truncate(block.text, 10240) });
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
} else if (block.type === "thinking" && block.thinking) {
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
} else if (block.type === "tool_use") {
@@ -1190,7 +1246,7 @@ function codexMessage(entry, num) {
const content = (p.content || [])
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
.filter((t) => t.trim() && !(p.role === "user" && CODEX_INJECTED.test(t.trimStart())))
.map((t) => ({ type: "text", text: truncate(t, 10240) }));
.map((t) => ({ type: "text", text: truncate(t, TEXT_MAX) }));
return content.length ? msg(p.role, p.role, content) : null;
}
case "reasoning": {
@@ -1230,7 +1286,9 @@ function codexMessage(entry, num) {
}
case "agent_message": {
const t = codexText(p.content);
return t.trim() ? msg("user", "system", [{ type: "text", text: truncate(t, 10240) }]) : null;
return t.trim()
? msg("user", "system", [{ type: "text", text: truncate(t, TEXT_MAX) }])
: null;
}
default:
return null;
@@ -1260,6 +1318,12 @@ function patchCounts(r) {
return null;
}
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
// the model's output and what the user typed. Only tool payloads and thinking
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
// against a pathological line, not a long answer.
const TEXT_MAX = 1024 * 1024;
function truncate(str, maxLen) {
if (!str || str.length <= maxLen) return str;
return str.slice(0, maxLen) + "[truncated]";