feat(workspace): no double resume, server-picked tabs, terminal a11y

- A resume of a session a live pane already holds is refused (409
  ESESSIONLIVE with error.run_id) and the client opens that pane, pasting
  any typed message there; catalog tools refuse resumeSessionId
  (EUNSUPPORTED) instead of silently starting fresh.
- POST /api/lanes/:id/start takes slot: "auto": the server picks the free
  tab under the lane lock (components/lanes/startSession.ts on the client,
  kept out of WorkspaceHome so the sidebar does not pull react-markdown
  into the main bundle).
- Unsaved whole-file edits are guarded on every exit (Back, another file,
  tab switch, close, lane/session change, beforeunload).
- Closing an AI tab asks first; hiding a pane has its own icon. Tabs and
  pane headers show the session name, a state dot, rename by double-click.
- Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F /
  Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52
  clipboard, a Reconnecting overlay, debounced resizes, optional screen
  reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes.
- Changes panel: draggable splitter, wrapping git bar, history paging,
  disarming discard/undo/abort, inline diff comments pasted only into a
  visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show
  more in the sidebar and Recents; idle dots readable with sr-only text.
- Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
2026-10-05 13:22:12 +07:00
parent 6715fbd971
commit efa417488a
43 changed files with 2252 additions and 507 deletions
+39
View File
@@ -11,7 +11,10 @@
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"d3": "^7.9.0",
"i18next": "^26.0.8",
@@ -2136,6 +2139,18 @@
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-clipboard": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^5.4.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
@@ -2145,6 +2160,24 @@
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-web-links": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
@@ -3711,6 +3744,12 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-base64": {
"version": "3.9.4",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz",
"integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+3
View File
@@ -14,7 +14,10 @@
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"d3": "^7.9.0",
"i18next": "^26.0.8",
+12 -5
View File
@@ -168,11 +168,17 @@ export function WorkspaceTree() {
}
};
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction, runId?: string) => {
if (action === "rename") setRenamingId(lane.id);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
else if (action === "started") select(lane.id);
// Start opens the session it started, as the home's menu does.
else if (action === "started")
navigate(
runId
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`
: `/workspace?lane=${lane.id}`
);
};
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
@@ -192,7 +198,8 @@ export function WorkspaceTree() {
}
};
const headBtn = "rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary";
// ≥24px targets (WCAG 2.5.8); each carries an aria-label.
const headBtn = "btn-icon";
return (
<section data-testid="workspace-tree" className="mt-3 border-t border-border pt-3">
<div className="mb-1 flex items-center gap-1 px-2">
@@ -205,7 +212,7 @@ export function WorkspaceTree() {
onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
aria-label={t("tree.toolFilter")}
title={t("tree.toolFilter")}
className="max-w-[6.5rem] rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] text-fg-secondary"
className="select max-w-[6.5rem] !rounded !py-0.5 !pl-1 !pr-5 !text-[11px] text-fg-secondary"
>
<option value="">{t("tree.allTools")}</option>
{tools.map((id) => (
@@ -307,7 +314,7 @@ export function WorkspaceTree() {
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)}
onAction={(a, runId) => onMenuAction(menu.lane, a, runId)}
/>
)}
{destructive && (
@@ -44,6 +44,11 @@ const session = (id: string, name: string) =>
let lanes: Lane[] = [];
// Archiving a live session asks through the themed confirm (App's host): yes.
vi.mock("../../lib/confirm", () => ({
confirmDialog: vi.fn(async () => true),
confirmDiscard: vi.fn(async () => true),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
@@ -65,6 +70,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
})),
patch: vi.fn(async () => ({ session: {} })),
// A tab's session with no row yet (a bare tab): 404.
get: vi.fn(async () => {
throw Object.assign(new Error("not found"), { status: 404 });
}),
},
run: {
list: vi.fn(async () => ({
@@ -483,6 +492,6 @@ describe("workspaceDot", () => {
expect(workspaceDot(lane(1, "/a", { status: "error" }))).toBe("bg-status-danger");
expect(workspaceDot(lane(1, "/a", { status: "running" }))).toContain("animate-pulse-slow");
expect(workspaceDot(lane(1, "/a", { liveness: "active" }))).toBe("bg-status-success");
expect(workspaceDot(lane(1, "/a"))).toBe("bg-surface-4");
expect(workspaceDot(lane(1, "/a"))).toBe("border border-fg-muted");
});
});
+3 -3
View File
@@ -65,7 +65,7 @@ function Seg({
aria-pressed={active}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
active
? "bg-accent text-white shadow-sm"
? "bg-accent-solid text-white shadow-sm"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
@@ -567,7 +567,7 @@ export function AddLaneModal({
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
className="select w-full !py-1.5 !text-xs"
>
{branches.map((b) => (
<option key={b} value={b}>
@@ -606,7 +606,7 @@ export function AddLaneModal({
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
className="select w-full !py-1.5 font-mono !text-xs"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
@@ -8,10 +8,11 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import { api } from "../../lib/api";
import { useDialog } from "../../hooks/useDialog";
export function FolderBrowseModal({
open,
@@ -45,14 +46,10 @@ export function FolderBrowseModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// The shared dialog contract: focus in, Tab trapped, Escape closes (only
// this one when it sits over the Add-workspace modal), focus restored.
const panel = useRef<HTMLDivElement>(null);
useDialog(open, panel, onClose);
if (!open) return null;
@@ -63,6 +60,7 @@ export function FolderBrowseModal({
role="presentation"
>
<div
ref={panel}
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
@@ -99,7 +97,7 @@ export function FolderBrowseModal({
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
+26 -40
View File
@@ -100,7 +100,8 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
// A ring: a surface-4 fill vanished on the light themes.
idle: "border border-fg-muted",
dead: "bg-status-danger",
};
@@ -128,7 +129,6 @@ export default function LaneCard({
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null);
@@ -203,9 +203,13 @@ export default function LaneCard({
<div key={name} className="flex items-center gap-1.5 truncate">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
info.listening ? "bg-status-success" : "bg-surface-4"
info.listening ? "bg-status-success" : "border border-fg-muted"
}`}
aria-hidden
/>
<span className="sr-only">
{info.listening ? t("runtime.listening") : t("runtime.notListening")}
</span>
<span className="text-fg-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && (
@@ -265,7 +269,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
className="truncate text-left text-accent hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
@@ -283,42 +287,23 @@ export default function LaneCard({
applies to managed lanes and is rare enough to stay behind ⋯. */}
{lane.kind === "managed" && (
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
<div className="relative">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
⋯
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
>
{t("action.reset")}
</button>
</div>
)}
</div>
{/* The shared menu shell: outside click, Escape and focus. */}
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={!!menuAt}
aria-label={t("moreActions")}
onClick={(e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
setMenuAt(menuAt ? null : { x: r.right - 160, y: r.bottom + 4 });
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
⋯
</button>
</div>
)}
</div>
@@ -327,6 +312,7 @@ export default function LaneCard({
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
<MenuItem
danger
testId="lane-action-reset"
onSelect={() => {
setMenuAt(null);
setDestructiveAction("reset");
+110 -22
View File
@@ -32,7 +32,15 @@
*
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter.
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
* a toast says which tab took it, and a console not on screen takes nothing.
*
* An unsaved whole-file edit is never dropped silently: Back, another file,
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
* reloading the browser tab asks the browser's own question, and
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
* switching its tab, or moving it to another workspace or session. Two-click
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -61,6 +69,12 @@ import {
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -428,14 +442,21 @@ function ChangeRow({
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
export function LaneChangesPanel({
laneId,
session,
onNewLane,
full = false,
tab = "changes",
onDirtyChange,
}: {
laneId: number;
/** The whole-file editor holds unsaved text (true) or not (false). */
onDirtyChange?: (dirty: boolean) => void;
/** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
@@ -457,7 +478,17 @@ export function LaneChangesPanel({
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
const comment = useCallback(
(text: string) => {
const runId = pasteToLane(laneId, text);
if (runId) {
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
}
return runId !== null;
},
[laneId, t]
);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
@@ -523,12 +554,21 @@ export function LaneChangesPanel({
}, [text]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [armedAbort, setArmedAbort] = useState(false);
// Two-click arming that disarms by itself after 3 s: the armed path, the
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
const discardArm = useArmed();
const confirmDiscard = discardArm.armed;
const setConfirmDiscard = (path: string | null) =>
path ? discardArm.arm(path) : discardArm.disarm();
const abortArm = useArmed();
const armedAbort = abortArm.armed !== null;
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
// A conflicted file opens in the conflict view; true = the plain editor.
const [rawEdit, setRawEdit] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null);
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
const hunkArm = useArmed();
const armedHunk = hunkArm.armed;
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
// Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
@@ -603,13 +643,19 @@ export function LaneChangesPanel({
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
// History reads HISTORY_PAGE commits, and Load more asks for the next page
// (up to the server's 200).
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (mode !== "history") return;
setHistoryLoading(true);
void api.lanes
.gitLog(target, { limit: 50 })
.gitLog(target, { limit: historyLimit })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId, reread]);
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
}, [mode, laneId, reread, historyLimit]);
// Re-run when a lane switch empties the cache too: the Files tab stays in
// `browse` across the switch, so the mode alone never changes.
@@ -739,8 +785,14 @@ export function LaneChangesPanel({
pendingScroll.current = null;
}, [laneId]);
// After the lane reset above: each tab opens on its own mode.
// Leaving the open file this way (the Workspace asked first when it was
// edited) drops it whole: a draft left behind would keep `dirty` true.
useEffect(() => {
if (!full) setMode(tab === "files" ? "browse" : "overview");
if (full) return;
setMode(tab === "files" ? "browse" : "overview");
setOpenFile(null);
setWhole(false);
setDraft(null);
}, [tab, laneId, full]);
const filesTab = tab === "files" && !full;
@@ -756,7 +808,11 @@ export function LaneChangesPanel({
pendingScroll.current = null;
}, [mode, data]);
const show = (path: string) => {
// Every way out of an open, edited file asks once; nothing else does.
const dirtyRef = useRef(false);
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
const showNow = (path: string) => {
// Opening a file FROM a file (the review list is gone by then) must not
// overwrite the list position that is still waiting to be restored.
if (mode !== "file") {
@@ -773,13 +829,21 @@ export function LaneChangesPanel({
setMode("file");
};
const goBack = () => {
const show = (path: string) => {
void confirmDrop().then((ok) => ok && showNow(path));
};
const goBackNow = () => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
setDraft(null);
};
const goBack = () => {
void confirmDrop().then((ok) => ok && goBackNow());
};
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
@@ -860,7 +924,7 @@ export function LaneChangesPanel({
setRange("commit");
setMode("review");
};
const copy = (text: string) => void navigator.clipboard.writeText(text);
const copy = (text: string) => void copyText(text);
// A menu item: close its menu, then act.
const pick = (close: () => void, fn: () => void) => () => {
close();
@@ -952,7 +1016,7 @@ export function LaneChangesPanel({
testId="lane-changes-tree-menu"
>
{!treeCtx.menu.item.dir && (
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
<MenuItem onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}>
{t("changes.openFile", { path: treeCtx.menu.item.name })}
</MenuItem>
)}
@@ -1090,6 +1154,14 @@ export function LaneChangesPanel({
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
dirtyRef.current = dirty;
useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange);
dirtyChangeRef.current = onDirtyChange;
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
// Unmounted with an edit open (the panel closed or moved on, after the
// Workspace asked): nothing is dirty any more.
useEffect(() => () => dirtyChangeRef.current?.(false), []);
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
@@ -1110,9 +1182,9 @@ export function LaneChangesPanel({
// A tree or quick-open pick opens the whole file — and, with an edit open,
// would drop it, so a dirty draft asks first.
const openWhole = (p: string) => {
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
show(p);
const openWhole = async (p: string) => {
if (!(await confirmDrop())) return;
showNow(p);
setWhole(true);
};
@@ -1156,7 +1228,7 @@ export function LaneChangesPanel({
<li key={p}>
<button
type="button"
onClick={() => openWhole(p)}
onClick={() => void openWhole(p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
@@ -1175,7 +1247,7 @@ export function LaneChangesPanel({
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={openWhole}
onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null}
/>
@@ -1235,7 +1307,7 @@ export function LaneChangesPanel({
onClick={() => setMode(m)}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m
? "bg-accent text-white"
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
@@ -1437,9 +1509,25 @@ export function LaneChangesPanel({
</button>
</li>
))}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
<li>
<button
type="button"
data-testid="lane-changes-history-more"
disabled={historyLoading}
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
</button>
</li>
)}
</ul>
) : mode === "browse" && !full ? (
browsePane
@@ -1469,7 +1557,7 @@ export function LaneChangesPanel({
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
}}
placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")}
@@ -1670,7 +1758,7 @@ export function LaneChangesPanel({
<button
type="button"
disabled={busy}
onClick={() => setDraft(null)}
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("changes.cancelEdit")}
+41 -6
View File
@@ -18,12 +18,16 @@
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* A line's comment box floats just under the clicked line number (fixed,
* re-placed as the diff scrolls), not under the whole diff.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
@@ -265,7 +269,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
/** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
* opens the comment box for that line. */
const CommentLine = createContext<{
open: (line: number) => void;
open: (line: number, anchor?: HTMLElement) => void;
title: (line: number) => string;
} | null>(null);
@@ -276,7 +280,7 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
<button
type="button"
data-testid={`diff-comment-line-${n}`}
onClick={() => comment.open(n)}
onClick={(e) => comment.open(n, e.currentTarget)}
title={comment.title(n)}
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
>
@@ -741,18 +745,34 @@ export function LaneDiffView({
const [commentAt, setCommentAt] = useState<number | null>(null);
const [draft, setDraft] = useState("");
const [undelivered, setUndelivered] = useState(false);
// The clicked line number: the box floats just under it, where the eye is,
// instead of under the whole (possibly 2000-line) diff.
const anchorEl = useRef<HTMLElement | null>(null);
const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
const place = useCallback(() => {
const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null;
if (r) setAnchor({ top: r.bottom + 2, left: r.left });
}, []);
useEffect(() => {
if (commentAt == null) return;
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [commentAt, place]);
const openComment = useMemo(
() =>
onComment
? {
open: (line: number) => {
open: (line: number, el?: HTMLElement) => {
anchorEl.current = el ?? null;
setAnchor(null);
place();
setCommentAt(line);
setUndelivered(false);
},
title: (line: number) => t("changes.commentLine", { n: line }),
}
: null,
[onComment, t]
[onComment, t, place]
);
const sendComment = () => {
const text = draft.trim();
@@ -812,7 +832,10 @@ export function LaneDiffView({
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
onScroll={(e) => {
win.onScroll(e.currentTarget);
if (commentAt != null) place();
}}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split"
? boxHeight(fill, splitWindowed)
@@ -855,7 +878,19 @@ export function LaneDiffView({
{commentAt != null && (
<form
data-testid="diff-comment-box"
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
style={
anchor
? {
position: "fixed",
top: Math.min(anchor.top, window.innerHeight - 180),
left: anchor.left,
width: "min(28rem, calc(100vw - 2rem))",
}
: undefined
}
className={`z-30 flex flex-col gap-1 border-border bg-surface-2 p-2 ${
anchor ? "rounded-lg border shadow-lg" : "border-t"
}`}
onSubmit={(e) => {
e.preventDefault();
sendComment();
+22 -10
View File
@@ -19,6 +19,8 @@
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
* in, so a remote wanting a login opens a form here and the SAME action is
* resent with the typed credential — the token is never rendered back.
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
* asks through the themed confirm; the rows wrap in a narrow panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -37,6 +39,9 @@ import {
Upload,
} from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
@@ -170,6 +175,8 @@ export function LaneGitBar({
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
setLogin(null);
setPassword("");
if (action === "fetch" || action === "pull" || action === "push" || action === "publish")
toast.success(t(`gitBar.done.${action}`));
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "publish") {
@@ -227,9 +234,14 @@ export function LaneGitBar({
await refreshBranches();
};
const merge = (branch: string) => {
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
void run("merge", { branch });
const merge = async (branch: string) => {
const ok = await confirmDialog({
title: t("gitBar.merge"),
message: t("gitBar.mergeConfirm", { branch, current: git.branch }),
confirmLabel: t("gitBar.merge"),
destructive: false,
});
if (ok) void run("merge", { branch });
};
const heldBy = new Map(
@@ -238,7 +250,7 @@ export function LaneGitBar({
return (
<div data-testid="lane-git-bar" className="space-y-1">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
@@ -285,7 +297,7 @@ export function LaneGitBar({
busy={busy === "merge"}
disabled={!!busy || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
onClick={() => merge(git.upstream as string)}
onClick={() => void merge(git.upstream as string)}
/>
)}
@@ -362,13 +374,13 @@ export function LaneGitBar({
</div>
{switching && (
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
onChange={(e) => setPicked(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
@@ -396,7 +408,7 @@ export function LaneGitBar({
busy={busy === "merge"}
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
onClick={() => merge(picked)}
onClick={() => void merge(picked)}
/>
{onNewLane && (
<Btn
@@ -436,7 +448,7 @@ export function LaneGitBar({
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newBranch.trim())
if (isSubmitEnter(e) && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
}}
placeholder={t("gitBar.branchNewPlaceholder")}
@@ -462,7 +474,7 @@ export function LaneGitBar({
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() });
}}
// The server decides which URLs are acceptable (https without a
// token, ssh, user@host:path); this only names the two usual ones.
@@ -267,7 +267,13 @@ export function LaneGitHeader({
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
onClick={() => setFilesOpen((v) => !v)}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
+1 -1
View File
@@ -20,7 +20,7 @@ import type { LaneNode } from "../../lib/types";
const STATE_CLASS: Record<LaneNode["state"], string> = {
done: "border-status-success/60 text-status-success bg-status-success/10",
current: "border-accent bg-accent text-white ring-2 ring-accent/30 shadow-sm",
current: "border-accent bg-accent-solid text-white ring-2 ring-accent/30 shadow-sm",
"passed-no-evidence": "border-status-warning/60 text-status-warning bg-status-warning/10",
failed: "border-status-danger/60 text-status-danger bg-status-danger/10",
pending: "border-border-light text-fg-muted bg-transparent",
+17 -12
View File
@@ -6,16 +6,18 @@
* opens its conversation — never a resume), open a live one in a split,
* rename / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does).
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
export function SessionMenu({
laneId,
@@ -39,7 +41,6 @@ export function SessionMenu({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
const pick = (fn: () => unknown) => () => {
@@ -48,12 +49,21 @@ export function SessionMenu({
};
const closeTab = async () => {
if (!run) return;
setError(null);
onClose();
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
@@ -79,7 +89,7 @@ export function SessionMenu({
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem onSelect={pick(() => void navigator.clipboard.writeText(session.id))}>
<MenuItem onSelect={pick(() => void copyText(session.id))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
@@ -92,11 +102,6 @@ export function SessionMenu({
{t("tabs.close")}
</MenuItem>
)}
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</ContextMenu>
);
}
+128 -52
View File
@@ -2,22 +2,52 @@
* @file SessionReplay — an ENDED session opened in its workspace
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
* an old one: its conversation, read-only (`ConversationView`), and a prompt
* box under it (a pasted image rides along as a thumbnail). Opening it resumes nothing; only SENDING a message does —
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the
* first free pane slot, after which `onResumed` opens the pane (`&run=`). The
* session's tool and folder come from its row (`GET /api/sessions/:id`), so a
* resume runs on the tool the session ran on (the server enforces that too).
* box under it (a pasted image rides along as a thumbnail). Opening it resumes
* nothing; only SENDING a message does — `POST /api/lanes/:id/start` with
* `resumeSessionId`, that prompt and `slot: "auto"` (the server picks the free
* tab), after which `onResumed` opens the pane (`&run=`). The panes are re-read
* first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude and Codex can resume a session by id —
* any other tool's session offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
* session ran on (the server enforces that too).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ArrowUp, SquareTerminal } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { freeSlot, paneRunId } from "./WorkspaceHome";
import { startInFreeTabDetailed } from "./startSession";
import { pasteToRun } from "../../lib/panePaste";
import { toast } from "../../lib/toast";
import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton";
/** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation);
* a toast says where it went — or, failing that, carries the text. */
function pasteWhenShown(runId: string, text: string) {
let tries = 0;
const tick = () => {
if (pasteToRun(runId, text)) toast.success(i18n.t("lanes:replay.pasted"));
else if (++tries < 30) window.setTimeout(tick, 100);
else toast.error(i18n.t("lanes:replay.notPasted", { text }));
};
window.setTimeout(tick, 0);
}
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({
lane,
@@ -27,15 +57,18 @@ export function SessionReplay({
}: {
lane: Lane;
sessionId: string;
/** Every live dashboard pane, to pick a free slot. */
/** Every live dashboard pane: a session one of them holds opens that pane. */
liveRuns: RunHandle[];
/** Opens the pane the resume started in. */
onResumed: (runId: string) => void;
/** Opens the pane the resume started in — of `laneId` when it is another
* (nested) workspace's pane that holds the session. */
onResumed: (runId: string, laneId?: number) => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +79,7 @@ export function SessionReplay({
setImages([]);
setError(null);
api.sessions
.get(sessionId)
.get(sessionId, { events: false })
.then((r) => live && setSession(r.session))
.catch(() => undefined);
return () => {
@@ -54,26 +87,32 @@ export function SessionReplay({
};
}, [sessionId]);
const tool = session?.tool || "claude";
const resumable = RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
const send = async () => {
const text = promptWithImages(prompt, images);
if (!text || busy) return;
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
if (!text || busy || uploading || livePane || !resumable) return;
setBusy(true);
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", {
slot,
resumeSessionId: sessionId,
prompt: text,
});
// Re-read: a tab that took this session since the list was read is
// opened, never resumed a second time.
const fresh = await api.run
.list()
.then((r) => r.items ?? [])
.catch(() => liveRuns);
const holder = liveRunFor(lane, fresh, sessionId);
const r = holder
? { runId: holder.id, laneId: lane.id, held: true }
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]);
onResumed(r.run_id ?? paneRunId(lane.id, slot));
onResumed(r.runId, r.laneId);
// Held already: the prompt reached nothing yet — paste it there.
if (r.held) pasteWhenShown(r.runId, text);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -83,42 +122,79 @@ export function SessionReplay({
return (
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
{session && (
<div className="flex justify-end">
<SessionResumeButton session={session} />
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} />
</div>
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
placeholder={t("replay.placeholder")}
rows={2}
slash={(session?.tool ?? "claude") === "claude"}
fileCwd={session?.cwd || lane.cwd}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
{livePane ? (
<div
data-testid="session-replay-live"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
type="button"
data-testid="session-replay-send"
onClick={() => void send()}
disabled={busy || (!prompt.trim() && images.length === 0)}
aria-label={t("replay.send")}
title={t("replay.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
onClick={() => onResumed(livePane.id)}
className="btn-ghost text-xs"
>
<ArrowUp className="h-4 w-4" />
<SquareTerminal className="h-4 w-4" />
{t("resume.openPane")}
</button>
</div>
{error && (
<p role="alert" className="mt-1 text-xs text-status-danger">
{error}
</p>
)}
</div>
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
type="button"
onClick={() => navigate(`/workspace?lane=${lane.id}`)}
className="btn-ghost text-xs"
>
{t("replay.newSession")}
</button>
</div>
) : (
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
placeholder={t("replay.placeholder")}
rows={2}
slash={tool === "claude"}
fileCwd={session?.cwd || lane.cwd}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
<button
type="button"
data-testid="session-replay-send"
onClick={() => void send()}
disabled={busy || uploading || !text}
aria-label={t("replay.send")}
title={t("replay.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
{error && (
<p role="alert" className="mt-1 text-xs text-status-danger">
{error}
</p>
)}
</div>
)}
</div>
);
}
@@ -7,8 +7,9 @@
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
* original untouched, and is offered even while the original has a live pane. A
* session that still has a live pane offers that pane instead. A resume
* starts in the workspace's first free pane slot (`POST /api/lanes/:id/start
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only
* starts in the workspace's first free pane slot, picked by the server
* (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, panes re-read
* first) and opens it — offered only
* for a session whose folder IS the workspace's, because the pane starts in
* the workspace's folder and Claude finds a session by the folder it ran in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -22,7 +23,7 @@ import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { freeSlot, paneRunId } from "./WorkspaceHome";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** The workspace of this tool whose folder holds `cwd` most deeply — the
@@ -81,8 +82,8 @@ export function SessionResumeButton({
(same(session.cwd, lane.cwd) ||
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
if (!run && !resumable) return null;
const open = (runId: string) =>
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
const open = (runId: string, laneId = lane.id) =>
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
const resume = async (fork: boolean) => {
// Busy first: the Confirm button is disabled only through it, and the
@@ -99,17 +100,12 @@ export function SessionResumeButton({
setRuns(fresh);
const live = liveRunFor(lane, fresh, session.id);
if (live && !fork) return open(live.id);
const slot = freeSlot(lane.id, fresh);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
const r = await api.lanes.action(lane.id, "start", {
slot,
// A pane of another (nested) workspace may hold it: open it there.
const r = await startInFreeTabDetailed(lane.id, {
resumeSessionId: session.id,
...(fork ? { forkSession: true } : {}),
});
open(r.run_id ?? paneRunId(lane.id, slot));
open(r.runId, r.laneId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
+178 -37
View File
@@ -10,8 +10,9 @@
* router's models, plus Effort); under it the quieter choices — AI Router
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
* then the archived ones folded with Unarchive; a live session opens its pane,
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* 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
@@ -20,6 +21,10 @@
*/
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";
@@ -64,22 +69,19 @@ import {
liveRunFor,
SessionFold,
sessionDot,
sessionDotLabel,
sessionWorktreeName,
useSessionPage,
} from "./WorkspaceSessionList";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
/** The first pane slot with no live run, or null when all four 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)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
}
export {
freeSlot,
laneOfRun,
paneRunId,
startInFreeTab,
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -88,6 +90,9 @@ function fmtBytes(n: number | null): string {
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
type HomeProps = Parameters<typeof HomeBody>[0];
@@ -105,8 +110,9 @@ function HomeBody({
onRenamed,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Row-menu items the page carries out (its modals); rename is done here.
* `started` comes with the run it opened. */
onMenuAction: (action: WorkspaceMenuAction, runId?: string) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
}) {
@@ -158,9 +164,17 @@ function HomeBody({
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [starting, setStarting] = useState(false);
const startingRef = useRef(false);
// An image still uploading: its path is not in the prompt yet.
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [sessions, setSessions] = useState<Session[] | null>(null);
const [sessionTotal, setSessionTotal] = useState(0);
const [recentLimit, setRecentLimit] = useState(RECENT_PAGE);
// 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);
@@ -172,6 +186,8 @@ function HomeBody({
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>();
@@ -186,13 +202,17 @@ function HomeBody({
.then((r) => live && setLiveRuns(r.items))
.catch(() => undefined);
api.sessions
.list({ lane: lane.id, archived: false, limit: 30 })
.then((r) => live && setSessions(r.sessions))
.list({ lane: lane.id, archived: false, limit: recentLimit })
.then((r) => {
if (!live) return;
setSessions(r.sessions);
setSessionTotal(r.total ?? r.sessions.length);
})
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.id, runKey, version]);
}, [lane.id, runKey, version, recentLimit]);
const archived = useSessionPage(
{ lane: lane.id, archived: true, limit: 30 },
`${runKey}:${version}`
@@ -269,18 +289,17 @@ function HomeBody({
}
};
const text = promptWithImages(prompt, images);
const start = async () => {
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
if (startingRef.current || uploading || !text) return;
startingRef.current = true;
setStarting(true);
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", {
slot,
prompt: promptWithImages(prompt, images),
const runId = await startInFreeTab(lane.id, {
prompt: text,
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
@@ -295,15 +314,61 @@ function HomeBody({
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]);
const runId = r.run_id ?? paneRunId(lane.id, slot);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
setError(message(err));
} finally {
startingRef.current = false;
setStarting(false);
}
};
// The sidebar row's Rename and Archive, for a Recents row.
const patchSession = async (s: Session, body: { name?: string; archived?: boolean }) => {
try {
await api.sessions.patch(s.id, body);
setVersion((v) => v + 1);
} catch (err) {
setError(message(err));
}
};
const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog(
wt && !live
? {
title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"),
}
: live
? {
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
);
if (!ok) return;
if (wt && !live) {
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
});
} catch (err) {
setError(message(err));
return;
}
}
await patchSession(s, { archived: true });
};
const openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id);
navigate(
@@ -364,7 +429,7 @@ function HomeBody({
onBlur={() => setTitle(null)}
onKeyDown={async (e) => {
if (e.key === "Escape") setTitle(null);
if (e.key !== "Enter") return;
if (!isSubmitEnter(e)) return;
const next = title.trim();
setTitle(null);
if (next === (lane.title || "")) return;
@@ -421,6 +486,7 @@ function HomeBody({
fileCwd={lane.cwd}
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
bare
/>
<div className="mt-1 flex items-center gap-1">
@@ -459,10 +525,10 @@ function HomeBody({
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting}
disabled={starting || uploading || !text}
aria-label={t("home.start")}
title={t("home.start")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
@@ -582,6 +648,29 @@ function HomeBody({
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
{(sessions ?? []).map((s) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
if (renamingId === s.id) {
return (
<li key={s.id} className="py-1.5">
<input
data-testid={`workspace-recent-rename-${s.id}`}
aria-label={t("tree.renameSession")}
autoFocus
value={nameDraft}
maxLength={200}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => setRenamingId(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenamingId(null);
if (!isSubmitEnter(e)) return;
setRenamingId(null);
const name = nameDraft.trim();
if (name && name !== s.name) void patchSession(s, { name });
}}
className="w-full rounded border border-accent bg-surface-1 px-2 py-1 text-sm text-fg-primary"
/>
</li>
);
}
return (
<li key={s.id}>
<button
@@ -592,7 +681,11 @@ function HomeBody({
onContextMenu={(e) => recentCtx.open(e, s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
<span
aria-hidden
className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`}
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, live)}`)}</span>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
@@ -618,6 +711,16 @@ function HomeBody({
);
})}
</ul>
{sessions !== null && sessionTotal > sessions.length && (
<button
type="button"
data-testid="workspace-recents-more"
onClick={() => setRecentLimit((n) => n + RECENT_PAGE)}
className="mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
</button>
)}
<div className="mt-2">
<SessionFold
testId={`workspace-archived-${lane.id}`}
@@ -632,7 +735,11 @@ function HomeBody({
</div>
</div>
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
<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) })}
@@ -887,11 +994,11 @@ function HomeBody({
onClick={() => setMemoryOpen(false)}
>
<div
ref={memoryPanel}
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
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">
@@ -919,9 +1026,30 @@ function HomeBody({
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"
@@ -933,7 +1061,12 @@ function HomeBody({
))}
</div>
{memory[memoryTab] && (
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto">
<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>
)}
@@ -949,6 +1082,12 @@ function HomeBody({
session={recentCtx.menu.item}
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
onClose={recentCtx.close}
onRename={() => {
const s = recentCtx.menu!.item;
setNameDraft(s.name || "");
setRenamingId(s.id);
}}
onArchive={() => void archiveSession(recentCtx.menu!.item)}
/>
)}
{sourceCtx.menu && (
@@ -960,7 +1099,7 @@ function HomeBody({
>
<MenuItem
onSelect={() => {
void navigator.clipboard.writeText(sourceCtx.menu!.item.path);
void copyText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
@@ -985,7 +1124,9 @@ function HomeBody({
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
onAction={(a, runId) =>
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
}
/>
)}
</div>
+38 -8
View File
@@ -6,7 +6,9 @@
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row.
* the lane row. Only a workspace row's own drag (`LANE_DRAG_TYPE`) is taken —
* a file, a tab or a text dragged over is not a reorder. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,6 +16,12 @@ import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types";
import { isSubmitEnter } from "../../lib/keys";
/** What a workspace row's drag carries: its index in the list. */
export const LANE_DRAG_TYPE = "application/x-agenthub-lane";
const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE);
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
export function workspaceDot(lane: Lane): string {
@@ -21,7 +29,17 @@ export function workspaceDot(lane: Lane): string {
if (lane.liveness === "dead" || lane.status === "error") return "bg-status-danger";
if (lane.status === "running") return "bg-status-success animate-pulse-slow";
if (lane.liveness === "active") return "bg-status-success";
return "bg-surface-4";
// A ring, not a fill: an idle surface-4 dot vanished on the light themes.
return "border border-fg-muted";
}
/** What the dot means (`row.state.*`), for a screen reader. */
export function workspaceDotLabel(lane: Lane): string {
if (lane.needs_action) return "needsYou";
if (lane.liveness === "dead" || lane.status === "error") return "error";
if (lane.status === "running") return "running";
if (lane.liveness === "active") return "active";
return "idle";
}
export function workspaceName(lane: Lane): string {
@@ -78,19 +96,23 @@ export function WorkspaceRow({
draggable={!renaming}
onDragStart={(e) => {
// Firefox starts no drag at all without a setData call.
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.setData(LANE_DRAG_TYPE, String(index));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
e.preventDefault();
setDropTarget(false);
const from = Number(e.dataTransfer.getData("text/plain"));
if (!isLaneDrag(e)) return;
e.preventDefault();
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
if (Number.isInteger(from)) onReorder(from, index);
}}
onContextMenu={openMenu}
@@ -125,7 +147,7 @@ export function WorkspaceRow({
// Blur CANCELS — Enter is the only save, so one edit fires one PATCH.
onBlur={() => onRenameEnd(null)}
onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft.trim());
if (isSubmitEnter(e)) onRenameEnd(draft.trim());
if (e.key === "Escape") onRenameEnd(null);
}}
className="my-1 min-w-0 flex-1 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[13px] text-fg-primary"
@@ -135,12 +157,19 @@ export function WorkspaceRow({
type="button"
data-testid={`workspace-open-${lane.id}`}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
// A double-click's second click renames instead of opening again.
onClick={(e) => e.detail < 2 && onSelect()}
onDoubleClick={() => {
setDraft(lane.title || "");
onRenameStart();
}}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
setDraft(lane.title || "");
onRenameStart();
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
@@ -148,7 +177,8 @@ export function WorkspaceRow({
title={`${lane.cwd}\n${t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] text-fg-primary">{name}</span>
</button>
)}
@@ -1,7 +1,10 @@
/**
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
* existing call: Start (`POST /api/lanes/:id/start`, the free tab the server
* picks — then the session opens, `onAction("started", runId)`) / Stop
* (`stop`, every tab of the workspace — counted from `GET /api/run` when the
* menu opens, and confirmed: "Stop N sessions?"),
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
@@ -10,11 +13,15 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
@@ -29,37 +36,70 @@ export function WorkspaceRowMenu({
x: number;
y: number;
onClose: () => void;
/** Items the tree itself carries out (rename field, modals, selection). */
onAction: (action: WorkspaceMenuAction) => void;
/** Items the tree itself carries out (rename field, modals, selection);
* `started` comes with the run it opened. */
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
}) {
const { t } = useTranslation("lanes");
const [error, setError] = useState<string | null>(null);
// Every live tab of the workspace — slot 0 alone undercounts a split.
const [liveCount, setLiveCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
api.run
.list()
.then(
(r) =>
alive &&
setLiveCount(
(r.items ?? []).filter((x) => x.laneId === lane.id && x.status === "running").length
)
)
.catch(() => undefined);
return () => {
alive = false;
};
}, [lane.id]);
const live = liveCount === null ? !!lane.run_id || lane.status === "running" : liveCount > 0;
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
setError(null);
const stop = async () => {
onClose();
const n = liveCount ?? 1;
if (
!(await confirmDialog({
title: t("menu.stopTitle", { count: n }),
message: t("menu.stopBody"),
confirmLabel: t("menu.stop"),
destructive: true,
}))
)
return;
try {
await fn();
if (then) onAction(then);
onClose();
await api.lanes.action(lane.id, "stop");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
const start = async () => {
onClose();
try {
onAction("started", await startInFreeTab(lane.id, { prompt: "" }));
} catch (err) {
toast.error(err);
}
};
const live = !!lane.run_id || lane.status === "running";
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem
onSelect={() =>
live
? void run(() => api.lanes.action(lane.id, "stop"))
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
}
>
<MenuItem onSelect={() => void (live ? stop() : start())}>
{live ? t("menu.stop") : t("menu.start")}
</MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
<MenuItem
onSelect={() => {
onClose();
void copyText(lane.cwd);
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
@@ -71,11 +111,6 @@ export function WorkspaceRowMenu({
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</ContextMenu>
);
}
@@ -17,10 +17,16 @@
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context.
* The list shows `SESSION_LIMIT` rows and a **Show N more** under them. A
* live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
@@ -57,7 +63,7 @@ export function sessionWorktreeName(lane: Lane, s: Session): string | null {
}
/** A session row's dot: waiting for the user, failed, held by a live pane, or
* at rest. */
* at rest (a ring, which reads on every theme). */
export function sessionDot(session: Session, live: boolean): string {
if (isSessionAwaitingInput(session)) return "bg-status-warning";
if (session.status === "error") return "bg-status-danger";
@@ -65,6 +71,13 @@ export function sessionDot(session: Session, live: boolean): string {
return "border border-fg-muted";
}
/** What the dot means, for a screen reader (`tree.state.*`). */
export function sessionDotLabel(session: Session, live: boolean): string {
if (isSessionAwaitingInput(session)) return "awaiting";
if (session.status === "error") return "error";
return live ? "live" : "idle";
}
export type SessionPage = { sessions: Session[]; total: number };
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
@@ -147,7 +160,13 @@ export function SessionFold({
onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
>
<Link
to={`/sessions/${s.id}`}
// An archived session opens in its workspace like any other
// (its replay), not on the analysis page.
to={
laneId === undefined
? `/sessions/${s.id}`
: `/workspace?lane=${laneId}&session=${encodeURIComponent(s.id)}`
}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
className="min-w-0 flex-1 truncate rounded-md py-1 text-[12px] text-fg-muted hover:text-fg-primary"
>
@@ -225,6 +244,8 @@ export function WorkspaceSessionList({
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>();
// Rows shown; Show more adds a page.
const [limit, setLimit] = useState(SESSION_LIMIT);
// This workspace's live AI tabs. A tab started bare (no prompt) has its
// session id from the start, but no session row until a hook creates one.
@@ -237,8 +258,9 @@ export function WorkspaceSessionList({
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
.join(",")}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
@@ -248,10 +270,41 @@ export function WorkspaceSessionList({
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
// A tab whose session HAS a row the list does not show is archived (or
// past the page): looked up once, so archiving a live session never brings
// it back as a New session.
const orphanIds =
sessions === null
? ""
: laneRuns
.filter((r) => r.sessionId && !sessions.some((s) => s.id === r.sessionId))
.map((r) => r.sessionId)
.join(",");
const [hasRow, setHasRow] = useState<Set<string>>(new Set());
useEffect(() => {
if (!orphanIds) return;
let live = true;
void Promise.all(
orphanIds.split(",").map((id) =>
api.sessions
// The row only: a session's events are its whole history.
.get(id, { events: false })
.then(() => id)
.catch(() => null)
)
).then((found) => live && setHasRow(new Set(found.filter((x): x is string => !!x))));
return () => {
live = false;
};
}, [orphanIds]);
const fresh =
sessions === null || q
? []
: laneRuns.filter((r) => !r.sessionId || !sessions.some((s) => s.id === r.sessionId));
: laneRuns.filter(
(r) =>
!r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
);
const count = sessions === null ? null : shown.length;
useEffect(() => {
if (count !== null) onMatches?.(count);
@@ -274,10 +327,21 @@ export function WorkspaceSessionList({
setDraft(s.name || "");
setRenaming(s.id);
};
const archive = (s: Session) =>
sessionWorktreeName(lane, s) && !runFor(s)
? setArchiving(s)
: void patch(s, { archived: true });
const archive = async (s: Session) => {
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
// A live one keeps running in its tab; archiving only hides its row.
if (
runFor(s) &&
!(await confirmDialog({
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}))
)
return;
await patch(s, { archived: true });
};
const archiveWithWorktree = async (s: Session) => {
setBusy(true);
setError(null);
@@ -347,7 +411,8 @@ export function WorkspaceSessionList({
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
@@ -373,7 +438,7 @@ export function WorkspaceSessionList({
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (e.key !== "Enter") return;
if (!isSubmitEnter(e)) return;
setRenaming(null);
const name = draft.trim();
if (name && name !== s.name) void patch(s, { name });
@@ -389,7 +454,10 @@ export function WorkspaceSessionList({
type="button"
data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined}
onClick={(e) =>
onClick={(e) => {
// A double-click's second click renames; it must not
// navigate (a second history entry) on top of the first.
if (e.detail > 1) return;
navigate(
run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
@@ -397,9 +465,15 @@ export function WorkspaceSessionList({
// Ctrl/Cmd+click a live one: open it in a split beside the
// focused pane (Workspace reads this state, not the URL).
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
)
}
);
}}
onDoubleClick={() => startRename(s)}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
startRename(s);
}
}}
onContextMenu={(e) => sessionCtx.open(e, s)}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s)
@@ -414,8 +488,10 @@ export function WorkspaceSessionList({
>
<span
data-testid={`workspace-session-dot-${s.id}`}
aria-hidden
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{sessionWorktreeName(lane, s) && (
<GitBranch
@@ -432,10 +508,12 @@ export function WorkspaceSessionList({
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => archive(s)}
onClick={() => void archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
// Invisible, not `hidden`: a keyboard user must be able to
// Tab onto it, and it shows when focused.
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<Archive className="h-3.5 w-3.5" />
</button>
@@ -443,6 +521,16 @@ export function WorkspaceSessionList({
);
})}
</ul>
{more > 0 && !q && (
<button
type="button"
data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => setLimit((n) => n + SESSION_LIMIT)}
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.showMore", { count: Math.min(more, SESSION_LIMIT) })}
</button>
)}
{failure}
{confirmArchive}
{ctx.menu && (
@@ -464,7 +552,7 @@ export function WorkspaceSessionList({
run={runFor(sessionCtx.menu.item)}
onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => archive(sessionCtx.menu!.item)}
onArchive={() => void archive(sessionCtx.menu!.item)}
/>
)}
</>
@@ -15,9 +15,15 @@ import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
vi.mock("../../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true),
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: {
@@ -134,7 +140,13 @@ beforeEach(() => {
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
function Tabbed({ laneId }: { laneId: number }) {
function Tabbed({
laneId,
onDirtyChange,
}: {
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
@@ -144,7 +156,7 @@ function Tabbed({ laneId }: { laneId: number }) {
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} />
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
}
@@ -404,7 +416,12 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel drops the edit and goes back to reading.
// Cancel asks before dropping an edit: "keep" keeps it…
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(confirmDiscard).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" drops it and goes back to reading.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
@@ -739,6 +756,33 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
@@ -16,6 +16,8 @@ import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
// A merge asks through the themed confirm (its host lives in App): yes.
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
@@ -371,7 +373,6 @@ describe("git login (issue #45)", () => {
describe("merge (issue #45)", () => {
it("merges the picked branch into the current one after a confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
@@ -380,11 +381,9 @@ describe("merge (issue #45)", () => {
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
);
confirm.mockRestore();
});
it("offers to merge the upstream when the branch has diverged", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
@@ -2,14 +2,16 @@
* @file SessionReplay.test.tsx
* @description An ended session opened in its workspace: its conversation and
* a prompt box; opening it starts nothing, and only a SENT message resumes it
* (`resumeSessionId` + the prompt, in the first free pane slot), after which
* the pane opens.
* (`resumeSessionId` + the prompt, in the free tab the server picks), after
* which the pane opens; a session a tab already holds opens that tab, and a
* tool that cannot resume by id offers a new session instead.
* @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 { Lane } from "../../../lib/types";
import type { RunHandle } from "../../../lib/api";
@@ -33,45 +35,112 @@ vi.mock("../../run/RunSetup", () => ({
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
vi.mock("../WorkspaceHome", () => ({
freeSlot: (_id: number, runs: RunHandle[]) => (runs.length ? 1 : 0),
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
vi.mock("../startSession", () => ({
startInFreeTabDetailed: vi.fn(async () => ({
runId: "agenthub-lane-1-1",
laneId: 1,
held: false,
})),
}));
vi.mock("../../../lib/panePaste", () => ({ pasteToRun: vi.fn(() => true) }));
vi.mock("../SessionResumeButton", () => ({ SessionResumeButton: () => null }));
vi.mock("../../../lib/api", () => ({
api: {
sessions: {
get: vi.fn(async () => ({ session: { id: "s-old", tool: "claude", cwd: "/r" } })),
},
lanes: { action: vi.fn(async () => ({ run_id: "agenthub-lane-1-1" })) },
run: { list: vi.fn(async () => ({ items: [] })) },
},
}));
import { api } from "../../../lib/api";
import { startInFreeTabDetailed as startInFreeTab } from "../startSession";
import { SessionReplay } from "../SessionReplay";
import { pasteToRun } from "../../../lib/panePaste";
const lane = { id: 1, cwd: "/r", tool: "claude" } as unknown as Lane;
const busy = [{ id: "agenthub-lane-1", laneId: 1, slot: 0, status: "running" }] as RunHandle[];
const holding = (sessionId: string) =>
[{ id: "agenthub-lane-1-2", laneId: 1, slot: 2, status: "running", sessionId }] as RunHandle[];
beforeEach(() => vi.clearAllMocks());
const renderIn = (ui: React.ReactElement) => render(<MemoryRouter>{ui}</MemoryRouter>);
describe("SessionReplay", () => {
it("shows the conversation and resumes nothing until a message is sent", async () => {
const onResumed = vi.fn();
const user = userEvent.setup();
render(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
expect(screen.getByTestId("conversation")).toHaveTextContent("s-old");
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old"));
// The row only — never the session's whole event history.
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old", { events: false }));
expect(screen.getByTestId("session-replay-send")).toBeDisabled();
expect(api.lanes.action).not.toHaveBeenCalled();
expect(startInFreeTab).not.toHaveBeenCalled();
await user.type(screen.getByLabelText("Message to continue this session…"), "carry on");
await user.click(screen.getByTestId("session-replay-send"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
resumeSessionId: "s-old",
prompt: "carry on",
// The server picks the free tab (`slot: "auto"`, inside startInFreeTab).
await waitFor(() =>
expect(startInFreeTab).toHaveBeenCalledWith(1, {
resumeSessionId: "s-old",
prompt: "carry on",
})
);
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1", 1));
});
it("opens the tab that already holds the session instead of resuming it again", async () => {
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(
<SessionReplay
lane={lane}
sessionId="s-old"
liveRuns={holding("s-old")}
onResumed={onResumed}
/>
);
expect(screen.queryByTestId("session-replay-send")).toBeNull();
await user.click(await screen.findByRole("button", { name: "Open pane" }));
expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(startInFreeTab).not.toHaveBeenCalled();
});
it("re-reads the panes on send: a tab that took the session meanwhile is opened", async () => {
vi.mocked(api.run.list).mockResolvedValueOnce({ items: holding("s-old") } as never);
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
await user.type(screen.getByLabelText("Message to continue this session…"), "go");
await user.click(screen.getByTestId("session-replay-send"));
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2", 1));
expect(startInFreeTab).not.toHaveBeenCalled();
// The typed message goes into that tab once it is on screen.
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-1-2", "go"));
});
it("opens the pane the server says holds it (even another workspace's) and pastes there", async () => {
vi.mocked(startInFreeTab).mockResolvedValueOnce({
runId: "agenthub-lane-9-1",
laneId: 9,
held: true,
});
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1"));
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
await user.type(screen.getByLabelText("Message to continue this session…"), "hi");
await user.click(screen.getByTestId("session-replay-send"));
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-9-1", 9));
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-9-1", "hi"));
});
it("offers a new session, not a resume, for a tool that cannot resume by id", async () => {
vi.mocked(api.sessions.get).mockResolvedValueOnce({
session: { id: "s-old", tool: "gemini", cwd: "/r" },
} as never);
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={vi.fn()} />);
expect(await screen.findByTestId("session-replay-no-resume")).toBeInTheDocument();
expect(screen.queryByTestId("session-replay-send")).toBeNull();
});
});
@@ -3,8 +3,8 @@
* @description The transcript page's explicit Resume and Fork: it shows only for a
* Claude session inside a workspace, a live session offers its pane instead,
* and a resume asks first (it re-reads the whole transcript), then starts in
* the first free pane slot and opens that pane — or refuses when all four are
* busy; a Fork asks too and starts a new session id beside a live original.
* the free pane slot the server picks (`slot: "auto"`) and opens that pane —
* or says so when all four are busy; a Fork asks too and starts a new session id beside a live original.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -81,7 +81,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript");
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-1",
});
await waitFor(() =>
@@ -97,7 +97,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(4, "start", {
slot: 0,
slot: "auto",
resumeSessionId: "s-2",
});
});
@@ -135,7 +135,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
await user.click(screen.getByRole("button", { name: "Fork session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-1",
forkSession: true,
});
@@ -147,7 +147,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-wt",
});
});
@@ -178,7 +178,12 @@ describe("SessionResumeButton", () => {
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
);
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: 2, resumeSessionId: "s-1" });
// The server picks the free tab under its lock; the client only re-reads
// to open a pane that took this session meanwhile.
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: "auto",
resumeSessionId: "s-1",
});
});
it("starts once on a double click while the panes are being re-read", async () => {
@@ -216,16 +221,31 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
});
it("refuses when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
it("says so when the server finds all four panes busy", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(api.lanes.action).not.toHaveBeenCalled();
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
});
it("opens the pane the server names when the session is already live there", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
// A nested workspace's pane holds it: opened in THAT workspace.
Object.assign(new Error("open"), { code: "ESESSIONLIVE", runId: "agenthub-lane-4-3" })
);
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=4&run=agenthub-lane-4-3"
)
);
});
it("shows nothing outside a workspace or for a non-Claude session", async () => {
@@ -24,13 +24,24 @@ vi.mock("../../run/RunSetup", () => ({
PromptEditor: ({
value,
onChange,
onSubmit,
placeholder,
}: {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
}) => (
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
<textarea
aria-label={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onSubmit();
}}
/>
),
ModelMenu: ({
value,
@@ -199,7 +210,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "fix the login",
permissionMode: "auto",
});
@@ -248,7 +259,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "port it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "port it",
tool: "codex",
sandbox: "workspace-write",
@@ -300,23 +311,40 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "try it",
permissionMode: "auto",
worktree: true,
});
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
it("says so when the server finds all four panes busy", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start"));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
});
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
let release: (v: { run_id: string }) => void = () => undefined;
vi.mocked(api.lanes.action).mockImplementationOnce(
() => new Promise((r) => (release = r)) as never
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
const box = screen.getByLabelText("How can I help you today?");
expect(screen.getByTestId("workspace-home-start")).toBeDisabled();
await user.type(box, "{Enter}");
expect(api.lanes.action).not.toHaveBeenCalled();
await user.type(box, "go{Enter}{Enter}");
release({ run_id: "agenthub-lane-1-1" });
await waitFor(() => expect(api.lanes.action).toHaveBeenCalledTimes(1));
});
it("folds the archived sessions with Unarchive", async () => {
@@ -0,0 +1,63 @@
/**
* @file startSession.ts — starting a session in a workspace's first free tab
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu
* can start a session without pulling the whole home (and react-markdown) into
* the main bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import i18n from "../../i18n";
import { api, type RunHandle } from "../../lib/api";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
/**
* Start a session in the workspace's first free tab. The SERVER picks the slot
* (`slot: "auto"`, under its lane lock), because any list the client holds may
* already be stale. Resolves to the run id it opened — or, when the session to
* resume is already open in a tab (`ESESSIONLIVE`), to that tab, so a second
* `--resume` of one session never starts.
*/
export async function startInFreeTab(
laneId: number,
body: Record<string, unknown>
): Promise<string> {
return (await startInFreeTabDetailed(laneId, body)).runId;
}
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
export function laneOfRun(runId: string): number | null {
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId);
return m ? Number(m[1]) : null;
}
/** `startInFreeTab`, saying also whether the run was STARTED or an existing
* pane already held the session (`held` — nothing was sent to it), and the
* workspace that pane belongs to (a nested folder's workspace may hold it). */
export async function startInFreeTabDetailed(
laneId: number,
body: Record<string, unknown>
): Promise<{ runId: string; laneId: number; held: boolean }> {
try {
const r = await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return { runId: r.run_id ?? paneRunId(laneId, 0), laneId, held: false };
} catch (err) {
const e = err as { code?: string; runId?: string };
if (e.code === "ESESSIONLIVE" && e.runId)
return { runId: e.runId, laneId: laneOfRun(e.runId) ?? laneId, held: true };
if (e.code === "ERUNLIVE") throw new Error(i18n.t("lanes:home.allBusy"));
throw err;
}
}
/** The first pane slot with no live run, or null when all four 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)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
}
+182 -30
View File
@@ -2,8 +2,14 @@
* @file ConsoleTabs.tsx
* @description The Workspace session view's tab bar: one tab per live tmux
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's
* `?run=`. A tab's X stops its session (`DELETE /api/run/:id`). The `+` menu
* opens a new tab in the first free slot — a plain shell
* `?run=`. A tab is named by its session (`sessions.name`, double-click or
* Rename to change it — `PATCH /api/sessions/:id`), else the prompt it started
* on, else its tool and number, and carries a state dot (waiting for you /
* error / running, with screen-reader text). A tab's X stops its session
* (`DELETE /api/run/:id`) — after a confirm for an AI tab, since it may be
* mid-turn; a shell closes at once. Arrow keys, Home and End move between tabs
* (one tab stop, APG). Errors are toasts. The `+` menu opens a new tab in the
* free slot the server picks (`slot: "auto"`) — a plain shell
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI
* tool started bare on its default model, no prompt (a prompt, router or
* worktree is still the workspace home's prompt box) — plus a link to
@@ -16,21 +22,74 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import type { RunHandle } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot } from "../lanes/WorkspaceHome";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { RunIcon, startPaneDrag } from "./PaneGrid";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
/** A tab's (and a pane header's) name: its session's own, else the first
* line of the prompt it started on, else its tool and tab number. */
export function runName(
r: RunHandle,
session: Session | undefined,
toolLabel: string,
terminal: string
): string {
if (r.tool === "shell") return terminal;
const prompt = r.promptPreview?.trim().split("\n")[0];
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
}
export type RunState = "awaiting" | "error" | "running";
/** What a live AI tab's dot says: waiting for you, failed, or at work. */
export function runState(session: Session | undefined): RunState {
if (session && isSessionAwaitingInput(session)) return "awaiting";
if (session?.status === "error") return "error";
return "running";
}
/** The state dot, with its meaning for a screen reader (colour alone says nothing). */
export function RunDot({ state }: { state: RunState }) {
const { t } = useTranslation("lanes");
const colour =
state === "awaiting"
? "bg-status-warning"
: state === "error"
? "bg-status-danger"
: "bg-status-success";
return (
<>
<span
data-state={state}
title={t(`tabs.state.${state}`)}
className={`h-1.5 w-1.5 shrink-0 rounded-full ${colour}`}
aria-hidden
/>
<span className="sr-only">{t(`tabs.state.${state}`)}</span>
</>
);
}
/** The tools a new tab or split offers: the workspace's own whatever its
* state, then every installed, enabled one. */
export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
@@ -63,10 +122,16 @@ export function ConsoleTabs({
onClosed,
onHome,
actions,
sessions,
onRenamed,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
/** The sessions the tabs hold, by id: their names and states. */
sessions?: Map<string, Session>;
/** A tab's session was renamed: re-read the sessions. */
onRenamed?: () => void;
/** The focused pane's run. */
activeRunId: string | null;
/** Runs the other panes show. */
@@ -84,7 +149,10 @@ export function ConsoleTabs({
const navigate = useNavigate();
const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// The tab being renamed (its run id) and the draft name.
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<RunHandle>();
@@ -103,31 +171,82 @@ export function ConsoleTabs({
}, [menuOpen]);
const slot = freeSlot(lane.id, tabs);
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
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 toolIds = newTabTools(lane, aiTools);
const open = async (body: Record<string, unknown>, split = false) => {
setMenuOpen(false);
if (slot === null) return;
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", { slot, ...body });
if (r.run_id) onOpen(r.run_id, split);
onOpen(await startInFreeTab(lane.id, body), split);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
const close = async (runId: string) => {
setError(null);
// An AI tab may be mid-turn: stopping it asks first. A shell holds no agent.
const close = async (r: RunHandle) => {
if (
r.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(r) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(runId);
onClosed(runId);
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
const startRename = (r: RunHandle) => {
if (!r.sessionId || r.tool === "shell") return;
setDraft(sessionOf(r)?.name ?? "");
setRenaming(r.id);
};
const saveRename = async (r: RunHandle) => {
setRenaming(null);
const name = draft.trim();
if (!r.sessionId || !name || name === sessionOf(r)?.name) return;
try {
await api.sessions.patch(r.sessionId, { name });
onRenamed?.();
} catch (err) {
toast.error(err);
}
};
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
const last = tabs.length - 1;
const to =
e.key === "ArrowRight"
? i === last
? 0
: i + 1
: e.key === "ArrowLeft"
? i === 0
? last
: i - 1
: e.key === "Home"
? 0
: e.key === "End"
? last
: null;
if (to === null || !tabs[to]) return;
e.preventDefault();
tabRefs.current.get(tabs[to].id)?.focus();
};
const tabStop = tabs.some((r) => r.id === activeRunId) ? activeRunId : tabs[0]?.id;
// A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
const r = tabCtx.menu?.item;
@@ -153,9 +272,32 @@ export function ConsoleTabs({
</button>
)}
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
{tabs.map((r) => {
{tabs.map((r, i) => {
const active = r.id === activeRunId;
const shown = !active && shownRunIds.includes(r.id);
if (renaming === r.id) {
return (
<div key={r.id} className="flex items-center px-2">
<input
data-testid={`console-tab-rename-${r.slot ?? 0}`}
aria-label={t("tree.renameSession")}
autoFocus
value={draft}
maxLength={200}
onChange={(e) => setDraft(e.target.value)}
// Blur cancels, Enter saves — as a sidebar session row.
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (!isSubmitEnter(e)) return;
e.preventDefault();
void saveRename(r);
}}
className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary"
/>
</div>
);
}
return (
<div
key={r.id}
@@ -182,18 +324,34 @@ export function ConsoleTabs({
type="button"
role="tab"
aria-selected={active}
tabIndex={r.id === tabStop ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(r.id, el);
else tabRefs.current.delete(r.id);
}}
onKeyDown={(e) => onTabKey(e, i)}
data-testid={`console-tab-${r.slot ?? 0}`}
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
title={[r.model, r.promptPreview, t("tabs.splitHint")].filter(Boolean).join("\n")}
onDoubleClick={() => startRename(r)}
title={[
toolLabel(r),
r.model,
r.promptPreview,
t("tabs.splitHint"),
r.sessionId ? t("tabs.renameHint") : null,
]
.filter(Boolean)
.join("\n")}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
<RunIcon run={r} label={label(r)} />
<RunIcon run={r} label={toolLabel(r)} />
<span className="truncate">{label(r)}</span>
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />}
</button>
<button
type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`}
onClick={() => void close(r.id)}
onClick={() => void close(r)}
aria-label={t("tabs.close")}
title={t("tabs.close")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${
@@ -262,11 +420,6 @@ export function ConsoleTabs({
</div>
)}
</div>
{error && (
<span role="alert" className="ml-2 min-w-0 self-center truncate text-xs text-status-danger">
{error}
</span>
)}
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
{tabCtx.menu && (
<ContextMenu
@@ -279,20 +432,19 @@ export function ConsoleTabs({
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
{t("sessionMenu.openSplit")}
</MenuItem>
{tabCtx.menu.item.sessionId && (
{tabCtx.menu.item.sessionId && tabCtx.menu.item.tool !== "shell" && (
<>
<MenuItem onSelect={pickTab(startRename)}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem
onSelect={pickTab((r) => void navigator.clipboard.writeText(r.sessionId ?? ""))}
>
<MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem danger onSelect={pickTab((r) => void close(r.id))}>
<MenuItem danger onSelect={pickTab((r) => void close(r))}>
{t("tabs.close")}
</MenuItem>
</ContextMenu>
@@ -102,7 +102,12 @@ export function LaneConsolePane({
autoAttachedForLane.current = laneId;
api.run
.get(attachRequest.runId)
.then((h) => live && setHandle(h))
.then((h) => {
if (!live) return;
setHandle(h);
// A later attach that worked clears an earlier one's failure.
setError(null);
})
.catch(
(err: unknown) =>
live &&
@@ -136,7 +141,7 @@ export function LaneConsolePane({
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-3">
{/* Not probed yet (a tool the page has not probed, or a shell) is not "missing". */}
{binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<div className="mx-3 mt-2 rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>
{t("binary.missing", {
@@ -148,7 +153,7 @@ export function LaneConsolePane({
)}
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<div className="mx-3 mt-2 rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
</div>
@@ -157,7 +162,7 @@ export function LaneConsolePane({
{!handle ? (
<div
data-testid="pane-no-run"
className="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"
className="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"
>
<p>{t("emptyPane")}</p>
{onNewSession && (
+48 -18
View File
@@ -8,16 +8,28 @@
* remount it and drop what it drew. With more than one pane each gets a slim
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or close the pane (its session keeps running as a tab).
* right or down, or HIDE the pane (its session keeps running as a tab — so
* its icon is not the tab's stop X). A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys.
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
* saved and the terminals refit once, not on every pointer move.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, Columns2, GripVertical, Rows2, SquareTerminal, X } from "lucide-react";
import {
Check,
ChevronDown,
Columns2,
GripVertical,
Minimize2,
Rows2,
SquareTerminal,
} from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
@@ -37,6 +49,7 @@ import {
swapInLayout,
} from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -283,8 +296,11 @@ export function PaneGrid({
tools,
toolLabel,
onNewSplit,
stateOf,
}: {
layout: PaneLayout;
/** A live AI run's state for its header dot (none for a shell). */
stateOf?: (r: RunHandle) => RunState | null;
/** Every slot to keep mounted; those not in the layout are hidden. */
mounted: number[];
/** The workspace's live runs (its tabs). */
@@ -433,6 +449,7 @@ export function PaneGrid({
<GripVertical className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
{run && <RunIcon run={run} label={label(run)} />}
<span className="min-w-0 truncate">{run ? label(run) : t("panes.empty")}</span>
{run && stateOf?.(run) && <RunDot state={stateOf(run)!} />}
<PaneMenu
testId={`pane-pick-${slot}`}
label={t("panes.pick")}
@@ -482,14 +499,14 @@ export function PaneGrid({
onClick={() => onChange((l) => closeInLayout(l, slot))}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<X className="h-3.5 w-3.5" />
<Minimize2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5">
{renderPane(slot)}
</div>
{/* No padding here: the terminal paints its own gutter in its
palette, so a dark pane never sits in a light-page frame. */}
<div className="flex min-h-0 flex-1 flex-col">{renderPane(slot)}</div>
</div>
);
})}
@@ -550,23 +567,31 @@ export function PaneGrid({
);
}
/** A split's divider: a 1px line with a wider grab area. */
function Divider({
/** A split's divider: a 1px line with a wider grab area. Also the Changes
* panel's splitter (Workspace.tsx), over the whole row (`box` = 0,0,1,1). */
export function Divider({
gridRef,
dir,
at,
box,
onRatio,
label,
}: {
gridRef: React.RefObject<HTMLDivElement>;
dir: SplitDir;
at: number;
box: Rect;
onRatio: (ratio: number) => void;
/** Accessible name; the panes' own by default. */
label?: string;
}) {
const { t } = useTranslation("lanes");
const [dragging, setDragging] = useState(false);
// The ratio under the pointer while dragging: only this line moves until
// release, when the layout takes it once.
const [live, setLive] = useState<number | null>(null);
const dragging = live !== null;
const row = dir === "row";
const pos = live ?? at;
const ratioAt = (e: React.PointerEvent) => {
const g = gridRef.current?.getBoundingClientRect();
if (!g || !g.width || !g.height) return null;
@@ -575,17 +600,22 @@ function Divider({
: ((e.clientY - g.top) / g.height - box.y) / box.h;
};
const style = row
? { left: `${(box.x + box.w * at) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
: { top: `${(box.y + box.h * at) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
? { left: `${(box.x + box.w * pos) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
: { top: `${(box.y + box.h * pos) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
const clamp = (r: number) => Math.min(0.9, Math.max(0.1, r));
const finish = () => {
if (live !== null && live !== at) onRatio(live);
setLive(null);
};
return (
<div
role="separator"
tabIndex={0}
aria-orientation={row ? "vertical" : "horizontal"}
aria-valuenow={Math.round(at * 100)}
aria-valuenow={Math.round(pos * 100)}
aria-valuemin={10}
aria-valuemax={90}
aria-label={t("panes.resize")}
aria-label={label ?? t("panes.resize")}
className={`group absolute z-10 flex justify-center outline-none ${
row
? "w-2 -translate-x-1/2 cursor-col-resize"
@@ -595,15 +625,15 @@ function Divider({
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
setDragging(true);
setLive(at);
}}
onPointerMove={(e) => {
if (!dragging) return;
const r = ratioAt(e);
if (r !== null) onRatio(r);
if (r !== null) setLive(clamp(r));
}}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onPointerUp={finish}
onPointerCancel={() => setLive(null)}
onKeyDown={(e) => {
const back = row ? "ArrowLeft" : "ArrowUp";
const fwd = row ? "ArrowRight" : "ArrowDown";
+14 -4
View File
@@ -43,6 +43,7 @@ import type { CwdSuggestion, CcMdItem } from "../../lib/api";
import type { LaneTool } from "../../lib/types";
import { Select } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
export interface SlashCommand {
name: string;
@@ -131,6 +132,9 @@ interface PromptEditorProps {
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */
bare?: boolean;
/** An image upload started (true) or settled (false): the caller holds its
* send button meanwhile, or the prompt would go out without the image. */
onBusyChange?: (busy: boolean) => void;
}
/** An attached image: its path on the server's machine and a local preview. */
@@ -153,6 +157,7 @@ export function PromptEditor({
images,
onImagesChange,
bare,
onBusyChange,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
@@ -265,6 +270,10 @@ export function PromptEditor({
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
const uploading = upload === "uploading";
const busyRef = useRef(onBusyChange);
busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => {
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
if (picked.length === 0) return false;
@@ -320,10 +329,11 @@ export function PromptEditor({
return;
}
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
// isSubmitEnter guards IME input (Vietnamese/CJK), where Enter commits the
// candidate. An image still uploading holds the send.
if (isSubmitEnter(e)) {
e.preventDefault();
onSubmit();
if (!uploading) onSubmit();
}
};
@@ -878,7 +888,7 @@ export function ModelMenu({
autoComplete="off"
spellCheck={false}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
if (!isSubmitEnter(e)) return;
e.preventDefault();
setCustom(false);
pick(e.currentTarget.value.trim());
+241 -18
View File
@@ -16,13 +16,29 @@
* and how `claude` attaches an image. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
* mode on Shift+Tab), so Ctrl+M toggles "Tab moves focus" — VS Code's escape
* from its terminal — announced by a hint the textarea is described by;
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
* (OSC 52 — tmux's default `set-clipboard external` passes its own
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
* READ the user's clipboard). While the socket is down a "Reconnecting…"
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
* most every 150 ms.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { ArrowDown, ArrowUp, X } from "lucide-react";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
@@ -30,6 +46,37 @@ import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
const readFlag = (key: string) => {
try {
return window.localStorage.getItem(key) === "1";
} catch {
return false;
}
};
/** OSC 52 from the pane may WRITE the clipboard (a tmux selection, a CLI's
* copy) but never read it: a read would hand the user's clipboard to
* whatever runs in the pane. */
const clipboardProvider: IClipboardProvider = {
readText: () => "",
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
// clipboard. An empty write (a "clear") is ignored.
writeText: (_selection, text) => {
// Silent and best effort: no toast per copy, and on a non-secure origin
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
},
};
// The addon's typings declare one argument, its code takes (base64, provider).
const ClipboardWithProvider = ClipboardAddon as unknown as new (
base64: undefined,
provider: IClipboardProvider
) => ClipboardAddon;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
interface TerminalViewProps {
@@ -62,7 +109,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
const { t } = useTranslation("run");
const tRef = useRef(t);
tRef.current = t;
const [drop, setDrop] = useState<DropState | null>(null);
// The socket dropped after it had been up: keystrokes are not reaching tmux.
const [reconnecting, setReconnecting] = useState(false);
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
const [tabMoves, setTabMoves] = useState(false);
const tabMovesRef = useRef(false);
const [find, setFind] = useState<string | null>(null);
const searchRef = useRef<SearchAddon | null>(null);
const findInput = useRef<HTMLInputElement>(null);
const [selectHint, setSelectHint] = useState(() => !readFlag(SELECT_HINT_KEY));
const hintId = useId();
// dragenter/dragleave fire for every child crossed, so count them: the hint
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
@@ -121,11 +180,22 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
const search = new SearchAddon();
term.loadAddon(search);
searchRef.current = search;
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
term.loadAddon(
new WebLinksAddon((_e, uri) => {
window.open(uri, "_blank", "noopener,noreferrer");
})
);
if (containerRef.current) term.open(containerRef.current);
term.textarea?.setAttribute("aria-describedby", hintId);
// Draw each cell at a fixed position instead of letting the browser lay the
// row out as text. xterm's default DOM renderer aligns a row with a single
@@ -171,8 +241,33 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
// here while working in a real terminal. Returning true keeps xterm's own
// handling; the preventDefault only stops focus from escaping.
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
// handling — and from cancelling — the key.
term.attachCustomKeyEventHandler((ev) => {
if (ev.type === "keydown" && ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
if (ev.type !== "keydown") return true;
const key = ev.key.toLowerCase();
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
ev.preventDefault();
tabMovesRef.current = !tabMovesRef.current;
setTabMoves(tabMovesRef.current);
return false;
}
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
// which is readline's forward-char (and bound by many TUIs).
const findChord =
!ev.altKey &&
key === "f" &&
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey));
if (findChord) {
ev.preventDefault();
setFind((f) => f ?? "");
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
});
@@ -192,11 +287,22 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
* (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing.
*/
const sendSize = () => {
const sendSizeNow = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
// A divider drag or a window resize fires a resize per frame; tmux and the
// program in the pane redraw on each, so the LAST size is sent once things
// settle (trailing, 150 ms).
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
const sendSize = () => {
if (sizeTimer) clearTimeout(sizeTimer);
sizeTimer = setTimeout(() => {
sizeTimer = null;
sendSizeNow();
}, 150);
};
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let ended = false; // server said the pane exited — nothing left to attach to
let disposed = false;
@@ -219,7 +325,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
sock.binaryType = "arraybuffer";
sock.onopen = () => {
sendSize();
setReconnecting(false);
sendSizeNow();
};
sock.onmessage = (event) => {
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
@@ -237,7 +344,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const msg = JSON.parse(data);
if (msg.type === "exit") {
ended = true;
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
onExitRef.current?.();
}
return;
@@ -251,7 +358,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
sock.onclose = () => {
if (disposed || ended) return;
// tmux keeps the session alive across a server restart, so re-attaching
// repaints the pane in full — no output is lost by retrying.
// repaints the pane in full — no output is lost by retrying. Input is
// not: the overlay says so rather than letting keystrokes vanish.
setReconnecting(true);
retryTimer = setTimeout(connect, 1500);
};
};
@@ -270,7 +379,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
// the window to whichever client was last active — resizing this pane out
// from under a terminal that is never told. Coming back to it says so.
const reassert = () => sendSize();
const reassert = () => sendSizeNow();
term.textarea?.addEventListener("focus", reassert);
const refit = () => {
@@ -296,6 +405,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
return () => {
disposed = true;
if (retryTimer) clearTimeout(retryTimer);
if (sizeTimer) clearTimeout(sizeTimer);
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
@@ -315,16 +426,46 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// Other parts of the page (a diff-line comment) paste into this pane.
useEffect(
() =>
registerPane(runId, (text) => {
termRef.current?.paste(text);
termRef.current?.focus();
}),
registerPane(
runId,
(text) => {
termRef.current?.paste(text);
termRef.current?.focus();
},
// On screen: a console hidden with CSS measures 0×0.
() => !!containerRef.current?.offsetWidth
),
[runId]
);
const runFind = (backwards = false) => {
const q = find ?? "";
if (!q) return;
if (backwards) searchRef.current?.findPrevious(q);
else searchRef.current?.findNext(q);
};
const closeFind = () => {
setFind(null);
searchRef.current?.clearDecorations();
termRef.current?.focus();
};
const dismissSelectHint = () => {
setSelectHint(false);
try {
window.localStorage.setItem(SELECT_HINT_KEY, "1");
} catch {
/* shown again next time */
}
};
const chip =
"absolute z-10 rounded-md border border-white/15 bg-black/70 px-2 py-1 text-[11px] text-white/90";
return (
<div
className="relative h-full w-full"
// The gutter around the terminal is the terminal's own colour, so a
// dark pane never sits in a light-page frame.
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
style={{ backgroundColor: palette.background }}
onDragEnter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
@@ -344,12 +485,94 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
onDrop={(e) => void onDrop(e)}
onFocus={() => notePaneFocus(runId)}
>
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
<p id={hintId} className="sr-only">
{t("terminal.keyboardHint")}
</p>
{tabMoves && (
<span data-testid="terminal-tab-moves" role="status" className={`${chip} bottom-2 right-3`}>
{t("terminal.tabMoves")}
</span>
)}
{selectHint && !reconnecting && (
<span className={`${chip} bottom-2 left-3 flex items-center gap-2`}>
{t("terminal.selectHint")}
<button
type="button"
onClick={dismissSelectHint}
aria-label={t("terminal.dismissHint")}
title={t("terminal.dismissHint")}
className="rounded p-0.5 hover:bg-white/15"
>
<X className="h-3 w-3" />
</button>
</span>
)}
{find !== null && (
<div
data-testid="terminal-find"
role="search"
className={`${chip} right-3 top-2 flex items-center gap-1 !p-1`}
>
<input
ref={findInput}
value={find}
aria-label={t("terminal.find")}
placeholder={t("terminal.find")}
onChange={(e) => {
setFind(e.target.value);
if (e.target.value)
searchRef.current?.findNext(e.target.value, { incremental: true });
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
closeFind();
} else if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
runFind(e.shiftKey);
}
}}
className="w-44 rounded bg-white/10 px-1.5 py-0.5 text-white placeholder:text-white/50 focus:outline-none"
/>
<button
type="button"
onClick={() => runFind(true)}
aria-label={t("terminal.findPrev")}
title={t("terminal.findPrev")}
className="rounded p-0.5 hover:bg-white/15"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => runFind()}
aria-label={t("terminal.findNext")}
title={t("terminal.findNext")}
className="rounded p-0.5 hover:bg-white/15"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={closeFind}
aria-label={t("terminal.findClose")}
title={t("terminal.findClose")}
className="rounded p-0.5 hover:bg-white/15"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{reconnecting && (
<div
data-testid="terminal-reconnecting"
role="status"
className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 text-sm text-white"
>
{t("terminal.reconnecting")}
</div>
)}
{drop && (
// pointer-events-none: the drop must land on the terminal, not on this.
<div
@@ -234,6 +234,60 @@ describe("TerminalView", () => {
expect(preventPlainTab).not.toHaveBeenCalled();
});
// WCAG 2.1.2: the terminal keeps Tab, so Ctrl+M must let the keyboard out.
it("lets Ctrl+M toggle Tab into moving focus out of the terminal", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ctrlM = () => new KeyboardEvent("keydown", { key: "m", ctrlKey: true });
// Not sent to the pane.
act(() => {
expect(handler(ctrlM())).toBe(false);
});
expect(screen.getByTestId("terminal-tab-moves")).toBeInTheDocument();
// false = xterm neither sends nor cancels it: the browser moves focus.
const tab = new KeyboardEvent("keydown", { key: "Tab" });
const prevent = vi.spyOn(tab, "preventDefault");
expect(handler(tab)).toBe(false);
expect(prevent).not.toHaveBeenCalled();
expect(handler(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true }))).toBe(false);
act(() => {
handler(ctrlM());
});
expect(screen.queryByTestId("terminal-tab-moves")).toBeNull();
expect(handler(new KeyboardEvent("keydown", { key: "Tab" }))).toBe(true);
});
it("opens find on Ctrl+Shift+F or Cmd+F, never on plain Ctrl+F (readline's forward-char)", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
expect(handler(new KeyboardEvent("keydown", { key: "f", ctrlKey: true }))).toBe(true);
expect(screen.queryByTestId("terminal-find")).toBeNull();
act(() => {
expect(
handler(new KeyboardEvent("keydown", { key: "F", ctrlKey: true, shiftKey: true }))
).toBe(false);
});
expect(screen.getByTestId("terminal-find")).toBeInTheDocument();
cleanup();
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const mac = keyHandlerMock.mock.calls[keyHandlerMock.mock.calls.length - 1]![0]! as (
e: KeyboardEvent
) => boolean;
act(() => {
expect(mac(new KeyboardEvent("keydown", { key: "f", metaKey: true }))).toBe(false);
});
expect(screen.getByTestId("terminal-find")).toBeInTheDocument();
});
it("says it is reconnecting while the socket is down, and clears it on reopen", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
act(() => ws.onopen?.());
expect(screen.queryByTestId("terminal-reconnecting")).toBeNull();
act(() => ws.onclose?.());
expect(screen.getByTestId("terminal-reconnecting")).toHaveTextContent("Reconnecting");
});
it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane.
@@ -275,7 +329,7 @@ describe("TerminalView", () => {
expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 }));
});
it("reports a size change xterm made on its own, with no container resize", () => {
it("reports a size change xterm made on its own, with no container resize", async () => {
// Webfont load and page zoom both move cols without resizing the element
// ResizeObserver watches, which is why onResize is the hook.
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
@@ -284,7 +338,12 @@ describe("TerminalView", () => {
ws.sent.length = 0;
termState.cols = 129;
onResizeHandlers.forEach((fn) => fn());
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 129, rows: 28 })]);
// A burst (a divider drag) reaches tmux once, with the last size.
termState.cols = 127;
onResizeHandlers.forEach((fn) => fn());
expect(ws.sent).toEqual([]);
await new Promise((r) => setTimeout(r, 200));
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 127, rows: 28 })]);
});
it("never reports a collapsed console's zero size", () => {
+42 -4
View File
@@ -74,7 +74,7 @@
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
"changes.commentSend": "Paste into pane",
"changes.commentCancel": "Cancel",
"changes.commentNoPane": "No pane of this workspace is open — open its session first",
"changes.commentNoPane": "No terminal of this workspace is on screen — open its session (or narrow the Changes panel) first",
"changes.layout.split": "Side-by-side",
"changes.layout.unified": "Unified",
"changes.loading": "Reading the working copy…",
@@ -390,7 +390,7 @@
"panes.split.bottom": "Split down",
"panes.full": "4 panes is the most a workspace can show",
"panes.newTerminal": "New terminal",
"panes.close": "Close pane (its session keeps running as a tab)",
"panes.close": "Hide pane (its session keeps running as a tab)",
"panes.resize": "Resize panes",
"tabs.home": "Workspace home",
"sessionMenu.open": "Open",
@@ -401,9 +401,47 @@
"panes.show": "Show {{name}} here",
"panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal",
"panes.closeShort": "Close pane",
"panes.closeShort": "Hide pane",
"changes.copyPath": "Copy relative path",
"changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA",
"changes.copySubject": "Copy commit message"
"changes.copySubject": "Copy commit message",
"replay.live": "This session is open in a tab — continue it there.",
"replay.noResume": "{{tool}} cannot resume a session by id, so this conversation is read-only here.",
"replay.newSession": "Start a new session",
"tabs.state.awaiting": "Waiting for you",
"tabs.state.error": "Error",
"tabs.state.running": "Running",
"tabs.renameHint": "Double-click to rename",
"tabs.stopTitle": "Stop “{{name}}”?",
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
"tabs.stopConfirm": "Stop session",
"changes.loadMore": "Load more commits",
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
"changes.splitter": "Resize the changes panel",
"tree.archiveLiveTitle": "Archive a live session?",
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
"tree.state.awaiting": "waiting for you",
"tree.state.error": "error",
"tree.state.live": "live",
"tree.state.idle": "ended",
"tree.showMore_one": "Show {{count}} more",
"tree.showMore_other": "Show {{count}} more",
"home.contextPanel": "Workspace context",
"row.state.needsYou": "needs you",
"row.state.error": "error",
"row.state.running": "running",
"row.state.active": "active",
"row.state.idle": "idle",
"menu.stopTitle_one": "Stop the running session?",
"menu.stopTitle_other": "Stop {{count}} running sessions?",
"menu.stopBody": "Every tab of this workspace stops, even mid-turn. Its sessions can be resumed later from Recents.",
"gitBar.done.fetch": "Fetched",
"gitBar.done.pull": "Pulled",
"gitBar.done.push": "Pushed",
"gitBar.done.publish": "Published",
"runtime.listening": "listening",
"runtime.notListening": "not listening",
"replay.pasted": "That session was already open in a tab — your message is pasted there; press Enter to send it.",
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}"
}
+12
View File
@@ -148,5 +148,17 @@
"imagesOnly": "Only images can be dropped here",
"remove": "Remove image",
"view": "View image"
},
"terminal": {
"ended": "[session ended, exit code {{code}}]",
"reconnecting": "Reconnecting… keystrokes are not reaching the session until the terminal is back.",
"keyboardHint": "Terminal. Press Ctrl+M to toggle whether Tab moves focus out of the terminal; Ctrl+Shift+F (Cmd+F on macOS) finds text.",
"tabMoves": "Tab moves focus — Ctrl+M to give Tab back to the terminal",
"selectHint": "Shift+drag to select text in the browser; a plain drag selects in tmux and copies on release.",
"dismissHint": "Dismiss",
"find": "Find in terminal",
"findPrev": "Previous match",
"findNext": "Next match",
"findClose": "Close find"
}
}
+42 -4
View File
@@ -74,7 +74,7 @@
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
"changes.commentSend": "Dán vào pane",
"changes.commentCancel": "Huỷ",
"changes.commentNoPane": "Workspace này chưa mở pane nào — hãy mở session trước",
"changes.commentNoPane": "Chưa có terminal nào của workspace này đang hiện — hãy mở session (hoặc thu hẹp khung Changes) trước",
"changes.layout.split": "Hai cột",
"changes.layout.unified": "Một cột",
"changes.loading": "Đang đọc working copy…",
@@ -390,7 +390,7 @@
"panes.split.bottom": "Tách xuống dưới",
"panes.full": "Một workspace hiện tối đa 4 pane",
"panes.newTerminal": "Terminal mới",
"panes.close": "Đóng pane (session vẫn chạy dưới dạng tab)",
"panes.close": "Ẩn pane (session vẫn chạy dưới dạng tab)",
"panes.resize": "Đổi kích thước pane",
"tabs.home": "Trang chủ workspace",
"sessionMenu.open": "Mở",
@@ -401,9 +401,47 @@
"panes.show": "Hiện {{name}} ở đây",
"panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
"panes.closeShort": "Đóng pane",
"panes.closeShort": "Ẩn pane",
"changes.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của commit",
"changes.copySubject": "Sao chép commit message"
"changes.copySubject": "Sao chép commit message",
"replay.live": "Session này đang mở trong một tab — hãy tiếp tục ở đó.",
"replay.noResume": "{{tool}} không thể tiếp tục (resume) một session theo id, nên hội thoại này chỉ để đọc.",
"replay.newSession": "Bắt đầu session mới",
"tabs.state.awaiting": "Đang chờ bạn",
"tabs.state.error": "Lỗi",
"tabs.state.running": "Đang chạy",
"tabs.renameHint": "Nhấp đúp để đổi tên",
"tabs.stopTitle": "Dừng “{{name}}”?",
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
"tabs.stopConfirm": "Dừng phiên",
"changes.loadMore": "Tải thêm commit",
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
"changes.splitter": "Đổi độ rộng khung thay đổi",
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
"tree.state.awaiting": "đang chờ bạn",
"tree.state.error": "lỗi",
"tree.state.live": "đang chạy",
"tree.state.idle": "đã kết thúc",
"tree.showMore_one": "Hiện thêm {{count}}",
"tree.showMore_other": "Hiện thêm {{count}}",
"home.contextPanel": "Ngữ cảnh workspace",
"row.state.needsYou": "cần bạn",
"row.state.error": "lỗi",
"row.state.running": "đang chạy",
"row.state.active": "đang hoạt động",
"row.state.idle": "nghỉ",
"menu.stopTitle_one": "Dừng phiên đang chạy?",
"menu.stopTitle_other": "Dừng {{count}} phiên đang chạy?",
"menu.stopBody": "Mọi tab của workspace này sẽ dừng, kể cả khi đang chạy dở. Có thể tiếp tục các phiên sau từ mục Gần đây.",
"gitBar.done.fetch": "Đã fetch",
"gitBar.done.pull": "Đã pull",
"gitBar.done.push": "Đã push",
"gitBar.done.publish": "Đã publish",
"runtime.listening": "đang lắng nghe",
"runtime.notListening": "không lắng nghe",
"replay.pasted": "Session đó đã mở trong một tab — tin nhắn đã được dán vào đó; nhấn Enter để gửi.",
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}"
}
+16 -4
View File
@@ -55,7 +55,7 @@
"approvalOnRequest": "Khi cần",
"cwd": "Thư mục làm việc",
"cwdGroups": {
"dashboard": "Dashboard server",
"dashboard": "Máy chủ dashboard",
"home": "Home",
"repo": "Repo nguồn",
"recent": "Gần đây - Claude Code đã dùng"
@@ -79,7 +79,7 @@
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"bypass": "Bỏ qua xin quyền cho lần chạy này",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Permission mode",
"permissionMode": "Chế độ quyền",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
@@ -92,7 +92,7 @@
"footer": {
"cost": "Chi phí",
"duration": "Thời lượng",
"exitCode": "Exit code",
"exitCode": "Mã thoát",
"sessionId": "Session ID",
"turns": "Lượt"
},
@@ -134,7 +134,7 @@
"session": "Session"
},
"tokens": {
"cacheRead": "Cache hit",
"cacheRead": "Trúng cache",
"cost": "Chi phí",
"input": "Input",
"label": "Context",
@@ -147,5 +147,17 @@
"imagesOnly": "Chỉ thả được ảnh vào đây",
"remove": "Bỏ ảnh",
"view": "Xem ảnh"
},
"terminal": {
"ended": "[phiên đã kết thúc, mã thoát {{code}}]",
"reconnecting": "Đang kết nối lại… phím gõ chưa tới được phiên cho tới khi terminal kết nối lại.",
"keyboardHint": "Terminal. Nhấn Ctrl+M để bật/tắt chế độ Tab chuyển focus ra khỏi terminal; Ctrl+Shift+F (Cmd+F trên macOS) để tìm chữ.",
"tabMoves": "Tab chuyển focus — Ctrl+M để trả Tab lại cho terminal",
"selectHint": "Shift+kéo để chọn chữ trong trình duyệt; kéo thường sẽ chọn trong tmux và sao chép khi thả chuột.",
"dismissHint": "Bỏ qua",
"find": "Tìm trong terminal",
"findPrev": "Kết quả trước",
"findNext": "Kết quả sau",
"findClose": "Đóng tìm kiếm"
}
}
+21 -3
View File
@@ -2,7 +2,8 @@
* @file panePaste.test.ts
* @description Pins where a pasted diff comment lands: the workspace's pane
* focused last, else any of its panes, never another workspace's (lane 1 is
* not lane 12), and nowhere once the pane unmounted.
* 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).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +17,7 @@ describe("panePaste", () => {
const off12 = reg("agenthub-lane-12");
const off0 = reg("agenthub-lane-1");
const off2 = reg("agenthub-lane-1-2");
expect(pasteToLane(1, "a")).toBe(true);
expect(pasteToLane(1, "a")).toBe("agenthub-lane-1");
expect(got["agenthub-lane-1"]).toEqual(["a"]);
notePaneFocus("agenthub-lane-1-2");
pasteToLane(1, "b");
@@ -27,7 +28,24 @@ describe("panePaste", () => {
expect(got["agenthub-lane-12"]).toBeUndefined();
off0();
off2();
expect(pasteToLane(1, "d")).toBe(false);
expect(pasteToLane(1, "d")).toBeNull();
off12();
});
it("never pastes into a pane that is not visible", () => {
const got: string[] = [];
let shown = false;
const off = registerPane(
"agenthub-lane-5",
(t) => got.push(t),
() => shown
);
notePaneFocus("agenthub-lane-5");
expect(pasteToLane(5, "x")).toBeNull();
expect(got).toEqual([]);
shown = true;
expect(pasteToLane(5, "y")).toBe("agenthub-lane-5");
expect(got).toEqual(["y"]);
off();
});
});
+34 -14
View File
@@ -4,18 +4,30 @@
* mounted TerminalView registers its run id; the text goes to the pane of that
* workspace the user focused last, else any of its panes. It is a paste —
* bracketed, so `claude` takes it as one block in its composer — and never an
* Enter: the user reads it and sends it.
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
* hidden behind a full-width Changes panel or a replay would swallow it
* unseen); the caller says where it went, or that it went nowhere.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const panes = new Map<string, (text: string) => void>();
interface Pane {
paste: (text: string) => void;
visible: () => boolean;
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
/** Registers a mounted terminal; returns its unregister. */
export function registerPane(runId: string, paste: (text: string) => void): () => void {
panes.set(runId, paste);
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
* unregister. */
export function registerPane(
runId: string,
paste: (text: string) => void,
visible: () => boolean = () => true
): () => void {
const entry = { paste, visible };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === paste) panes.delete(runId);
if (panes.get(runId) === entry) panes.delete(runId);
if (lastFocused === runId) lastFocused = null;
};
}
@@ -30,13 +42,21 @@ function ofLane(runId: string, laneId: number): boolean {
return runId === base || runId.startsWith(`${base}-`);
}
/** Pastes `text` into one of the workspace's mounted panes; false when none is. */
export function pasteToLane(laneId: number, text: string): boolean {
const id =
lastFocused && panes.has(lastFocused) && ofLane(lastFocused, laneId)
? lastFocused
: [...panes.keys()].find((k) => ofLane(k, laneId));
if (!id) return false;
panes.get(id)!(text);
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
* false otherwise (not mounted yet, or hidden). */
export function pasteToRun(runId: string, text: string): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text);
return true;
}
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
* last when it is — and returns its run id; null when none is on screen. */
export function pasteToLane(laneId: number, text: string): string | null {
const ok = (id: string) => ofLane(id, laneId) && !!panes.get(id)?.visible();
const id = lastFocused && ok(lastFocused) ? lastFocused : [...panes.keys()].find(ok);
if (!id) return null;
panes.get(id)!.paste(text);
return id;
}
+286 -84
View File
@@ -10,6 +10,14 @@
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
* isn't owned by any lane, calls POST /api/lanes/ensure first.
* Tabs and pane headers name each live session (`runName`) and show its state;
* a tab click replaces the history entry (Back leaves the session view, not
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
* splits the focused one right with a new terminal, Alt+W hides it. The
* Changes panel resizes by its button's presets or by dragging (or arrow keys
* on) its splitter; 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.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -57,15 +65,30 @@ import {
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { ConsoleTabs, newTabBody, newTabTools } from "../components/run/ConsoleTabs";
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid";
import {
ConsoleTabs,
newTabBody,
newTabTools,
runName,
runState,
} from "../components/run/ConsoleTabs";
import {
Divider,
type LayoutUpdate,
PaneGrid,
SplitMenus,
usePaneLayout,
} from "../components/run/PaneGrid";
import {
type Edge,
type PaneLayout,
MAX_PANES,
closeInLayout,
leaves,
moveInLayout,
@@ -74,7 +97,8 @@ import {
pruneLayout,
} from "../lib/paneLayout";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot } from "../components/lanes/WorkspaceHome";
import { freeSlot, 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";
@@ -105,8 +129,10 @@ type PanelTab = (typeof PANEL_TABS)[number]["id"];
* 30/70 → 50/50 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "full"] as const;
type ChangesSize = (typeof CHANGES_SIZES)[number];
const CHANGES_WIDTH: Record<ChangesSize, string> = {
type ChangesPreset = (typeof CHANGES_SIZES)[number];
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
type ChangesSize = ChangesPreset | number;
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
compact: "w-[30%]",
half: "w-1/2",
// The console beside it is HIDDEN, not unmounted: a live run's terminal
@@ -115,11 +141,20 @@ const CHANGES_WIDTH: Record<ChangesSize, string> = {
};
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, the full width, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
compact: Columns2,
half: Maximize2,
full: PanelRightClose,
};
/** The panel's share of the row, for the splitter. */
const changesRatio = (s: ChangesSize) =>
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
/** The preset a size counts as for the button: under ~45% is narrow. */
const changesStep = (s: ChangesSize): ChangesPreset =>
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
/** The panel's own minimum (`min-w-[18rem]`), in px. */
const CHANGES_MIN_PX = 288;
// ── Page ──────────────────────────────────────────────────────────────
@@ -161,8 +196,12 @@ function ConsoleArea({
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
/** Back to the workspace home, where a new session is started. */
onNewSession?: () => void;
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus. */
onOpenRun: (runId: string, opts?: { split?: boolean; focusOnly?: boolean }) => void;
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus,
* `replace` adds no history entry (a tab click). */
onOpenRun: (
runId: string,
opts?: { split?: boolean; focusOnly?: boolean; replace?: boolean }
) => void;
/** A tab was opened or closed: re-read the run list now. */
onRunsChanged: () => void;
}) {
@@ -195,8 +234,26 @@ function ConsoleArea({
const exited = useRef(new Set<string>());
const laneRef = useRef(selectedLaneId);
laneRef.current = selectedLaneId;
const [splitError, setSplitError] = useState<string | null>(null);
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
// The sessions the tabs hold — their names and states — re-read when a tab
// opens or learns its session, when the lane's status moves, or on a rename.
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
const [sessionsVersion, setSessionsVersion] = useState(0);
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
useEffect(() => {
if (selectedLaneId === null || !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]))))
.catch(() => undefined);
return () => {
live = false;
};
}, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
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 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}`;
@@ -293,17 +350,16 @@ function ConsoleArea({
const newSplit = async (target: number, edge: Edge, tool?: string) => {
if (!lane || free === null) return;
const laneId = lane.id;
const slot = free;
setSplitError(null);
try {
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
const r = await api.lanes.action(laneId, "start", { slot, ...body });
// The server picks the slot: the list `free` came from may be stale.
const slot = slotOf(await startInFreeTab(laneId, body));
onRunsChanged();
if (!r.run_id || laneRef.current !== laneId) return;
if (laneRef.current !== laneId) return;
pending.current.set(slot, activeRuns);
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
} catch (err) {
setSplitError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
@@ -316,8 +372,43 @@ function ConsoleArea({
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
const focusSlot = view.focus;
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
// terminal, Alt+W hides it. Session view only, and never from inside a
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
// terminal first). Read through a ref: one listener, always current.
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;
// 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;
if (!areaRef.current || areaRef.current.closest(".hidden")) return;
const el = e.target as HTMLElement | null;
if (el?.closest?.(".xterm")) return;
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return;
const digit = /^Digit([1-4])$/.exec(e.code);
if (digit) {
const s = shown[Number(digit[1]) - 1];
if (s === undefined) return;
e.preventDefault();
change((l) => (l.root && l.focus !== s ? { ...l, focus: s } : l));
} else if (e.code === "Backslash" && focusSlot !== null) {
e.preventDefault();
if (free !== null && shown.length < MAX_PANES) void newSplit(focusSlot, "right");
} else if (e.code === "KeyW" && focusSlot !== null && shown.length > 1) {
e.preventDefault();
change((l) => closeInLayout(l, focusSlot));
}
};
useEffect(() => {
const on = (e: KeyboardEvent) => onKeys.current(e);
document.addEventListener("keydown", on);
return () => document.removeEventListener("keydown", on);
}, []);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div ref={areaRef} className="flex min-h-0 flex-1 flex-col">
{lane && (
<ConsoleTabs
lane={lane}
@@ -326,8 +417,12 @@ function ConsoleArea({
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
onOpen={(runId, split) => {
onRunsChanged();
onOpenRun(runId, { split });
// A tab click is not a new history entry: Back leaves the
// session view instead of walking back through every tab.
onOpenRun(runId, { split, replace: true });
}}
sessions={tabSessions}
onRenamed={() => setSessionsVersion((v) => v + 1)}
onClosed={(runId) => ended(slotOf(runId))}
onHome={onNewSession}
// While one pane is shown, its split menus (a split pane has its own).
@@ -351,11 +446,6 @@ function ConsoleArea({
}
/>
)}
{splitError && (
<p role="alert" className="px-3 pt-2 text-xs text-status-danger">
{splitError}
</p>
)}
<PaneGrid
layout={view}
mounted={mounted}
@@ -366,6 +456,7 @@ function ConsoleArea({
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
@@ -433,7 +524,10 @@ export function Workspace() {
(prev) => {
const next = new URLSearchParams(prev);
// A run belongs to its lane: another lane opens on its home.
if (next.get("lane") !== String(id)) next.delete("run");
if (next.get("lane") !== String(id)) {
next.delete("run");
next.delete("session");
}
if (id === null) next.delete("lane");
else next.set("lane", String(id));
return next;
@@ -454,7 +548,10 @@ export function Workspace() {
}
if (paramId === null) setSelectedLaneId(selectedLaneId);
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
// A lane action's failure, as a toast (it stays until dismissed).
const setLaneActionError = (message: string | null) => {
if (message) toast.error(tLanes("actionError", { message }));
};
const [destructive, setDestructive] = useState<{
lane: Lane;
action: "reset" | "remove";
@@ -516,32 +613,119 @@ export function Workspace() {
/* private mode / disabled storage: the panel just forgets between visits */
}
}, [changesOpen]);
// A preset name, or the fraction a splitter drag left (stored as a number).
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
try {
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null;
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact";
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY);
if (saved && (CHANGES_SIZES as readonly string[]).includes(saved))
return saved as ChangesPreset;
const n = Number(saved);
return saved && Number.isFinite(n) ? clampChanges(n) : "compact";
} catch {
return "compact";
}
});
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize);
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
} catch {
/* same as above: a forgotten width, not an error */
}
}, [changesSize]);
const changesFull = changesSize === "full";
const changesPreset = changesStep(changesSize);
const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel =
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel = changesFull
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const changesRowRef = useRef<HTMLDivElement>(null);
// The row's width: the panel never gets narrower than its 18rem minimum,
// so the splitter is drawn — and dragged — no further than that edge.
const [rowWidth, setRowWidth] = useState(0);
useEffect(() => {
const el = changesRowRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setRowWidth(el.clientWidth));
ro.observe(el);
setRowWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
const minChanges = rowWidth ? Math.min(0.85, CHANGES_MIN_PX / rowWidth) : 0.15;
// The session the opened run holds, when that run works in a worktree of
// its own (`claude --worktree`): the Changes panel shows that tree.
// its own (`claude --worktree`): the Changes panel shows that tree. A
// replayed session (`?session=`) shows its own worktree the same way.
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined;
// Its worktree is checked once (`GET …/git?session=`): an ended session's
// worktree Claude already removed answers ENOWORKTREE, and the panel then
// shows the workspace's own folder instead of an error.
const [replayed, setReplayed] = useState<{ id: string; worktree: boolean } | null>(null);
const replayLane = lanes.find((l) => l.id === selectedLaneId);
useEffect(() => {
setReplayed(null);
if (!sessionParam || !replayLane) return;
let live = true;
void (async () => {
try {
const { session } = await api.sessions.get(sessionParam, { events: false });
if (!sessionWorktreeName(replayLane, session)) {
if (live) setReplayed({ id: sessionParam, worktree: false });
return;
}
const ok = await api.lanes
.git({ lane: replayLane.id, session: sessionParam })
.then(() => true)
.catch((err: { code?: string }) => err?.code !== "ENOWORKTREE");
if (live) setReplayed({ id: sessionParam, worktree: ok });
} catch {
/* no row: the workspace's folder */
}
})();
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id
}, [sessionParam, replayLane?.id]);
const gitSession =
openedRun?.worktree && openedRun.sessionId
? openedRun.sessionId
: sessionParam && replayed?.id === sessionParam && replayed.worktree
? sessionParam
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace,
// session or worktree) would remount it and drop the edit — until the user
// says to discard it, or saves.
const [changesDirty, setChangesDirty] = useState(false);
const wantPanel =
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
const [panelAt, setPanelAt] = useState(wantPanel);
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
const asking = useRef(false);
useEffect(() => {
if (wantPanelKey === panelKey) return;
if (!changesDirty) {
setPanelAt(wantPanel);
return;
}
if (asking.current) return;
asking.current = true;
void confirmDiscard().then((ok) => {
asking.current = false;
if (!ok) return;
setChangesDirty(false);
setPanelAt(wantPanel);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
}, [wantPanelKey, panelKey, changesDirty]);
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
const afterDrop = (fn: () => void) => {
if (!changesDirty) return fn();
void confirmDiscard().then((ok) => ok && fn());
};
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
// installed since the page loaded clears its missing-binary banner. Claude is
@@ -697,8 +881,7 @@ export function Workspace() {
// editor: no page padding, no cards, dividers between its regions.
const sessionView = !!currentLane && !home;
const viewportLocked =
sessionView ||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full")));
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -807,7 +990,7 @@ export function Workspace() {
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onMenuAction={(action) => {
onMenuAction={(action, runId) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
@@ -816,17 +999,17 @@ export function Workspace() {
} else if (action === "started") {
setSearchParams({
lane: String(currentLane.id),
run: `agenthub-lane-${currentLane.id}`,
run: runId ?? `agenthub-lane-${currentLane.id}`,
});
}
}}
/>
)}
<div className={`flex min-h-0 flex-1${home ? " hidden" : ""}`}>
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col${
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
currentLane && changesOpen && changesFull ? " hidden" : ""
}`}
>
{/* No header row: the console's tab bar is the header. Only a
@@ -840,7 +1023,7 @@ export function Workspace() {
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs text-fg-secondary"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
@@ -873,7 +1056,7 @@ export function Workspace() {
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
>
@@ -966,8 +1149,8 @@ export function Workspace() {
lane={currentLane}
sessionId={sessionParam}
liveRuns={activeRuns?.items ?? []}
onResumed={(runId) =>
setSearchParams({ lane: String(currentLane.id), run: runId })
onResumed={(runId, laneId) =>
setSearchParams({ lane: String(laneId ?? currentLane.id), run: runId })
}
/>
</div>
@@ -992,8 +1175,9 @@ export function Workspace() {
setSearchParams(
{ lane: String(currentLane.id), run: runId },
{
// Following the focus is not a new history entry.
replace: opts?.focusOnly,
// Following the focus, or a tab click, is not a new
// history entry.
replace: opts?.focusOnly || opts?.replace,
state: opts?.split
? { split: true }
: opts?.focusOnly
@@ -1008,14 +1192,29 @@ export function Workspace() {
</div>
</section>
{currentLane && changesOpen && !changesFull && (
// Drag (or arrow keys) to any width; the button keeps its presets.
<Divider
gridRef={changesRowRef}
dir="row"
at={1 - Math.max(changesRatio(changesSize), minChanges)}
box={{ x: 0, y: 0, w: 1, h: 1 }}
label={tLanes("changes.splitter")}
onRatio={(r) => setChangesSize(clampChanges(Math.max(1 - r, minChanges)))}
/>
)}
{currentLane &&
(changesOpen ? (
<aside
id="lane-changes-panel"
data-testid="lane-changes-card"
className={`flex shrink-0 flex-col bg-surface-1 ${CHANGES_WIDTH[changesSize]}${
changesSize === "full" ? "" : " border-l border-border"
}`}
aria-label={tLanes("changes.title")}
style={
typeof changesSize === "number" ? { width: `${changesSize * 100}%` } : undefined
}
className={`flex shrink-0 flex-col bg-surface-1 ${
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Activity bar: Files · Changes · Sessions, then the resize
control — the same height as the console's tab bar, so the
@@ -1030,7 +1229,12 @@ export function Workspace() {
type="button"
data-testid={`panel-tab-${id}`}
aria-pressed={panelTab === id}
onClick={() => setPanelTab(id)}
// Narrow, a tab switch leaves the open file (and its
// edit); at full width the editor stays put.
onClick={() =>
panelTab !== id &&
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
}
title={tLanes(`panel.${id}`)}
aria-label={tLanes(`panel.${id}`)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
@@ -1062,10 +1266,13 @@ export function Workspace() {
data-testid="lane-changes-resize"
onClick={() => {
// Past the widest step the next state is "gone", which
// is what the rail button reopens at `compact`.
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) {
setChangesOpen(false);
setChangesSize("compact");
// is what the rail button reopens at `compact` — and
// what drops an unsaved edit, so that asks first.
if (changesFull) {
afterDrop(() => {
setChangesOpen(false);
setChangesSize("compact");
});
} else {
setChangesSize(nextChangesSize);
}
@@ -1082,31 +1289,35 @@ export function Workspace() {
{/* Icon-only, and it draws the width the click LANDS on,
not the current one — the label the tooltip carries. */}
{(() => {
const Next = CHANGES_NEXT_ICON[changesSize];
const Next = CHANGES_NEXT_ICON[changesPreset];
return <Next className="h-4 w-4" />;
})()}
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
<LaneChangesPanel
// A new tree is a new panel: no read for the previous one
// can land in it.
key={`${currentLane.id}:${gitSession ?? ""}`}
laneId={currentLane.id}
session={gitSession}
tab={panelTab}
full={changesSize === "full"}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
{panelAt && (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous one
// can land in it. `panelAt` holds still while an edit is
// unsaved (see above).
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
tab={panelTab}
full={changesFull}
onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
)}
</div>
</aside>
) : (
@@ -1167,15 +1378,6 @@ export function Workspace() {
}}
/>
)}
{laneActionError && (
<p
role="alert"
className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
>
{tLanes("actionError", { message: laneActionError })}
</p>
)}
</div>
);
}
+33 -6
View File
@@ -55,6 +55,8 @@ let nextRunId = 1;
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
}));
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
@@ -63,6 +65,25 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
},
// The server picks the slot (`slot: "auto"`); stand in for it with the first
// one the mocked run list leaves free.
startInFreeTab: async (laneId: number, body: Record<string, unknown>) => {
const { api } = await import("../../lib/api");
const { items } = await api.run.list();
const busy = new Set(
(items as Array<{ laneId: number; slot?: number; status: string }>)
.filter((r) => r.laneId === laneId && r.status === "running")
.map((r) => r.slot ?? 0)
);
const slot = [0, 1, 2, 3].find((x) => !busy.has(x)) ?? 0;
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
},
}));
// Closing an AI tab asks first (the themed confirm needs App's host): yes.
vi.mock("../../lib/confirm", () => ({
confirmDialog: vi.fn(async () => true),
confirmDiscard: vi.fn(async () => true),
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({
@@ -243,6 +264,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
get: vi.fn().mockImplementation(async (id: string) => ({
session: { id, cwd: "/workspace/a", tool: "claude" },
})),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
@@ -594,7 +618,7 @@ describe("console tabs", () => {
const user = userEvent.setup();
await user.click(screen.getByTestId("console-tab-new"));
await user.click(within(screen.getByTestId("console-tab-menu")).getByText("New terminal"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 1, shell: true });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("disables + once all four slots are taken", async () => {
@@ -782,12 +806,15 @@ describe("split panes", () => {
await right(screen.getByTestId("pane-header-1"));
await user.click(screen.getByTestId("pane-menu-split-bottom"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 3, shell: true });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
// The new terminal (the slot the server picked, 3) opened under pane 1.
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-3"]);
await right(screen.getByTestId("pane-header-1"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Close pane"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
await settle();
expect(shownPanes()).toEqual(["pane-0"]);
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
expect(api.run.kill).not.toHaveBeenCalled();
await right(screen.getByTestId("console-tab-2"));
@@ -804,7 +831,7 @@ describe("split panes", () => {
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-bottom-1"));
await user.click(screen.getByTestId("pane-split-new-bottom-1"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("a split menu starts an AI tool, bare, in the first free slot", async () => {
@@ -815,7 +842,7 @@ describe("split panes", () => {
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, prompt: "" });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
});
it("with one pane, the split menus sit beside Tools and split it", async () => {