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/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.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", "@xterm/xterm": "^5.5.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^26.0.8", "i18next": "^26.0.8",
@@ -2136,6 +2139,18 @@
"@xterm/xterm": "^5.0.0" "@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": { "node_modules/@xterm/addon-fit": {
"version": "0.10.0", "version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
@@ -2145,6 +2160,24 @@
"@xterm/xterm": "^5.0.0" "@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": { "node_modules/@xterm/xterm": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
@@ -3711,6 +3744,12 @@
"jiti": "bin/jiti.js" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "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/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.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", "@xterm/xterm": "^5.5.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^26.0.8", "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); if (action === "rename") setRenamingId(lane.id);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd); else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action }); 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 }) => { 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 ( return (
<section data-testid="workspace-tree" className="mt-3 border-t border-border pt-3"> <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"> <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 })} onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
aria-label={t("tree.toolFilter")} aria-label={t("tree.toolFilter")}
title={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> <option value="">{t("tree.allTools")}</option>
{tools.map((id) => ( {tools.map((id) => (
@@ -307,7 +314,7 @@ export function WorkspaceTree() {
x={menu.x} x={menu.x}
y={menu.y} y={menu.y}
onClose={() => setMenu(null)} onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)} onAction={(a, runId) => onMenuAction(menu.lane, a, runId)}
/> />
)} )}
{destructive && ( {destructive && (
@@ -44,6 +44,11 @@ const session = (id: string, name: string) =>
let lanes: Lane[] = []; 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) => { vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
return { return {
@@ -65,6 +70,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
sessions: [session("s-live", "Live one"), session("s-old", "Old one")], sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
})), })),
patch: vi.fn(async () => ({ session: {} })), 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: { run: {
list: vi.fn(async () => ({ 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: "error" }))).toBe("bg-status-danger");
expect(workspaceDot(lane(1, "/a", { status: "running" }))).toContain("animate-pulse-slow"); 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", { 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} aria-pressed={active}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${ className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
active 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" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`} }`}
> >
@@ -567,7 +567,7 @@ export function AddLaneModal({
id="add-lane-base" id="add-lane-base"
value={base} value={base}
onChange={(e) => setBase(e.target.value)} 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) => ( {branches.map((b) => (
<option key={b} value={b}> <option key={b} value={b}>
@@ -606,7 +606,7 @@ export function AddLaneModal({
id="add-lane-existing" id="add-lane-existing"
value={branch} value={branch}
onChange={(e) => setBranch(e.target.value)} 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) => { {branches.map((b) => {
const busyAt = busyBranches.get(b); const busyAt = busyBranches.get(b);
@@ -8,10 +8,11 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react"; import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useDialog } from "../../hooks/useDialog";
export function FolderBrowseModal({ export function FolderBrowseModal({
open, open,
@@ -45,14 +46,10 @@ export function FolderBrowseModal({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]); }, [open]);
useEffect(() => { // The shared dialog contract: focus in, Tab trapped, Escape closes (only
if (!open) return; // this one when it sits over the Add-workspace modal), focus restored.
const onKey = (e: KeyboardEvent) => { const panel = useRef<HTMLDivElement>(null);
if (e.key === "Escape") onClose(); useDialog(open, panel, onClose);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null; if (!open) return null;
@@ -63,6 +60,7 @@ export function FolderBrowseModal({
role="presentation" role="presentation"
> >
<div <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" 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()} onClick={(e) => e.stopPropagation()}
role="dialog" 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" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
> >
{entry.isGitRepo ? ( {entry.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" /> <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> = { export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success", 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", dead: "bg-status-danger",
}; };
@@ -128,7 +129,6 @@ export default function LaneCard({
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null); const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null); const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
const [runtimeBump, setRuntimeBump] = useState(0); const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null); 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"> <div key={name} className="flex items-center gap-1.5 truncate">
<span <span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${ 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-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span> <span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && ( {info.port !== null && info.port !== info.expected && (
@@ -265,7 +269,7 @@ export default function LaneCard({
<button <button
type="button" type="button"
onClick={() => onSelectLane?.(w.id)} 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} title={w.cwd}
> >
#{w.id} {w.title || w.cwd} · {w.status} #{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 ⋯. */} applies to managed lanes and is rare enough to stay behind ⋯. */}
{lane.kind === "managed" && ( {lane.kind === "managed" && (
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs"> <div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
<div className="relative"> {/* The shared menu shell: outside click, Escape and focus. */}
<button <button
type="button" type="button"
data-testid="lane-more" data-testid="lane-more"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menuOpen} aria-expanded={!!menuAt}
onClick={(e) => { aria-label={t("moreActions")}
e.stopPropagation(); onClick={(e) => {
setMenuOpen((v) => !v); e.stopPropagation();
}} const r = e.currentTarget.getBoundingClientRect();
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary" setMenuAt(menuAt ? null : { x: r.right - 160, y: r.bottom + 4 });
title={t("moreActions")} }}
> className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
⋯ title={t("moreActions")}
</button> >
{menuOpen && ( ⋯
<div </button>
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>
</div> </div>
)} )}
</div> </div>
@@ -327,6 +312,7 @@ export default function LaneCard({
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}> <ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
<MenuItem <MenuItem
danger danger
testId="lane-action-reset"
onSelect={() => { onSelect={() => {
setMenuAt(null); setMenuAt(null);
setDestructiveAction("reset"); setDestructiveAction("reset");
+110 -22
View File
@@ -32,7 +32,15 @@
* *
* A diff line's number opens a comment box (LaneDiffView `onComment`); the * A diff line's number opens a comment box (LaneDiffView `onComment`); the
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -61,6 +69,12 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, gitTarget } from "../../lib/api"; import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste"; 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 { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView"; import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; 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({ export function LaneChangesPanel({
laneId, laneId,
session, session,
onNewLane, onNewLane,
full = false, full = false,
tab = "changes", tab = "changes",
onDirtyChange,
}: { }: {
laneId: number; 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`) /** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it, * instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */ * 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. // Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session); const target = gitTarget(laneId, session);
// A diff line's comment goes into this workspace's pane, as a paste. // 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 // 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. // history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0); const [reread, setReread] = useState(0);
@@ -523,12 +554,21 @@ export function LaneChangesPanel({
}, [text]); }, [text]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null); // Two-click arming that disarms by itself after 3 s: the armed path, the
const [armedAbort, setArmedAbort] = useState(false); // 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. // A conflicted file opens in the conflict view; true = the plain editor.
const [rawEdit, setRawEdit] = useState(false); const [rawEdit, setRawEdit] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}`. // The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null); 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 // 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 // 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. // 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 // 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. // 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(() => { useEffect(() => {
if (mode !== "history") return; if (mode !== "history") return;
setHistoryLoading(true);
void api.lanes void api.lanes
.gitLog(target, { limit: 50 }) .gitLog(target, { limit: historyLimit })
.then(forLane(laneId, (r) => setCommits(r.commits))) .then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err))); .catch((err) => setError(err instanceof Error ? err.message : String(err)))
}, [mode, laneId, reread]); .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 // 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. // `browse` across the switch, so the mode alone never changes.
@@ -739,8 +785,14 @@ export function LaneChangesPanel({
pendingScroll.current = null; pendingScroll.current = null;
}, [laneId]); }, [laneId]);
// After the lane reset above: each tab opens on its own mode. // 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(() => { 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]); }, [tab, laneId, full]);
const filesTab = tab === "files" && !full; const filesTab = tab === "files" && !full;
@@ -756,7 +808,11 @@ export function LaneChangesPanel({
pendingScroll.current = null; pendingScroll.current = null;
}, [mode, data]); }, [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 // 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. // overwrite the list position that is still waiting to be restored.
if (mode !== "file") { if (mode !== "file") {
@@ -773,13 +829,21 @@ export function LaneChangesPanel({
setMode("file"); setMode("file");
}; };
const goBack = () => { const show = (path: string) => {
void confirmDrop().then((ok) => ok && showNow(path));
};
const goBackNow = () => {
const back = returnTo.current; const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0; pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null; returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview")); setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null); setOpenFile(null);
setWhole(false); 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 // Save the editor over exactly the bytes it opened (`sha`): a file the agent
@@ -860,7 +924,7 @@ export function LaneChangesPanel({
setRange("commit"); setRange("commit");
setMode("review"); 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. // A menu item: close its menu, then act.
const pick = (close: () => void, fn: () => void) => () => { const pick = (close: () => void, fn: () => void) => () => {
close(); close();
@@ -952,7 +1016,7 @@ export function LaneChangesPanel({
testId="lane-changes-tree-menu" testId="lane-changes-tree-menu"
> >
{!treeCtx.menu.item.dir && ( {!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 })} {t("changes.openFile", { path: treeCtx.menu.item.name })}
</MenuItem> </MenuItem>
)} )}
@@ -1090,6 +1154,14 @@ export function LaneChangesPanel({
}); });
const dirty = draft !== null && draft !== text?.lines.join("\n"); 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 files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path)); 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, // A tree or quick-open pick opens the whole file — and, with an edit open,
// would drop it, so a dirty draft asks first. // would drop it, so a dirty draft asks first.
const openWhole = (p: string) => { const openWhole = async (p: string) => {
if (dirty && !window.confirm(t("changes.dropEdits"))) return; if (!(await confirmDrop())) return;
show(p); showNow(p);
setWhole(true); setWhole(true);
}; };
@@ -1156,7 +1228,7 @@ export function LaneChangesPanel({
<li key={p}> <li key={p}>
<button <button
type="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" className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
> >
{p} {p}
@@ -1175,7 +1247,7 @@ export function LaneChangesPanel({
children={treeChildren} children={treeChildren}
expanded={expandedDirs} expanded={expandedDirs}
onToggleDir={toggleDir} onToggleDir={toggleDir}
onOpenFile={openWhole} onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open} onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null} active={full && mode === "file" ? openFile : null}
/> />
@@ -1235,7 +1307,7 @@ export function LaneChangesPanel({
onClick={() => setMode(m)} onClick={() => setMode(m)}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${ className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m mode === m
? "bg-accent text-white" ? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`} }`}
> >
@@ -1437,9 +1509,25 @@ export function LaneChangesPanel({
</button> </button>
</li> </li>
))} ))}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && ( {commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li> <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> </ul>
) : mode === "browse" && !full ? ( ) : mode === "browse" && !full ? (
browsePane browsePane
@@ -1469,7 +1557,7 @@ export function LaneChangesPanel({
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && message.trim() && !busy) void commitStaged(); if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
}} }}
placeholder={t("changes.commitPlaceholder")} placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")} aria-label={t("changes.commitPlaceholder")}
@@ -1670,7 +1758,7 @@ export function LaneChangesPanel({
<button <button
type="button" type="button"
disabled={busy} 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" 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")} {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 * 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 * (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { import {
createContext, createContext,
useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, 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 /** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
* opens the comment box for that line. */ * opens the comment box for that line. */
const CommentLine = createContext<{ const CommentLine = createContext<{
open: (line: number) => void; open: (line: number, anchor?: HTMLElement) => void;
title: (line: number) => string; title: (line: number) => string;
} | null>(null); } | null>(null);
@@ -276,7 +280,7 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
<button <button
type="button" type="button"
data-testid={`diff-comment-line-${n}`} data-testid={`diff-comment-line-${n}`}
onClick={() => comment.open(n)} onClick={(e) => comment.open(n, e.currentTarget)}
title={comment.title(n)} 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" 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 [commentAt, setCommentAt] = useState<number | null>(null);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [undelivered, setUndelivered] = useState(false); 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( const openComment = useMemo(
() => () =>
onComment onComment
? { ? {
open: (line: number) => { open: (line: number, el?: HTMLElement) => {
anchorEl.current = el ?? null;
setAnchor(null);
place();
setCommentAt(line); setCommentAt(line);
setUndelivered(false); setUndelivered(false);
}, },
title: (line: number) => t("changes.commentLine", { n: line }), title: (line: number) => t("changes.commentLine", { n: line }),
} }
: null, : null,
[onComment, t] [onComment, t, place]
); );
const sendComment = () => { const sendComment = () => {
const text = draft.trim(); const text = draft.trim();
@@ -812,7 +832,10 @@ export function LaneDiffView({
ref={root} ref={root}
data-testid="lane-diff-view" data-testid="lane-diff-view"
data-layout={layout} 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] ${ className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split" layout === "split"
? boxHeight(fill, splitWindowed) ? boxHeight(fill, splitWindowed)
@@ -855,7 +878,19 @@ export function LaneDiffView({
{commentAt != null && ( {commentAt != null && (
<form <form
data-testid="diff-comment-box" 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) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
sendComment(); sendComment();
+22 -10
View File
@@ -19,6 +19,8 @@
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt * 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 * 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. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -37,6 +39,9 @@ import {
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api"; 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"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action = type Action =
@@ -170,6 +175,8 @@ export function LaneGitBar({
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length })); setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
setLogin(null); setLogin(null);
setPassword(""); 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 // 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. // created, deleted or switched to, so it is re-read rather than patched.
if (action === "publish") { if (action === "publish") {
@@ -227,9 +234,14 @@ export function LaneGitBar({
await refreshBranches(); await refreshBranches();
}; };
const merge = (branch: string) => { const merge = async (branch: string) => {
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return; const ok = await confirmDialog({
void run("merge", { branch }); 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( const heldBy = new Map(
@@ -238,7 +250,7 @@ export function LaneGitBar({
return ( return (
<div data-testid="lane-git-bar" className="space-y-1"> <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} {lead}
{/* Ahead/behind first: it is the fact that decides which of the {/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */} buttons beside it the user actually wants. */}
@@ -285,7 +297,7 @@ export function LaneGitBar({
busy={busy === "merge"} busy={busy === "merge"}
disabled={!!busy || dirty > 0} disabled={!!busy || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")} 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> </div>
{switching && ( {switching && (
<div className="flex items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<select <select
aria-label={t("gitBar.switchLabel")} aria-label={t("gitBar.switchLabel")}
value={picked} value={picked}
disabled={!!busy} disabled={!!busy}
onChange={(e) => setPicked(e.target.value)} 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> <option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => { {(branches || []).map((b) => {
@@ -396,7 +408,7 @@ export function LaneGitBar({
busy={busy === "merge"} busy={busy === "merge"}
disabled={!picked || picked === git.branch || dirty > 0 || !!busy} disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")} title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
onClick={() => merge(picked)} onClick={() => void merge(picked)}
/> />
{onNewLane && ( {onNewLane && (
<Btn <Btn
@@ -436,7 +448,7 @@ export function LaneGitBar({
value={newBranch} value={newBranch}
onChange={(e) => setNewBranch(e.target.value)} onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && newBranch.trim()) if (isSubmitEnter(e) && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() }); void run("branch-create", { branch: newBranch.trim() });
}} }}
placeholder={t("gitBar.branchNewPlaceholder")} placeholder={t("gitBar.branchNewPlaceholder")}
@@ -462,7 +474,7 @@ export function LaneGitBar({
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { 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 // The server decides which URLs are acceptable (https without a
// token, ssh, user@host:path); this only names the two usual ones. // token, ssh, user@host:path); this only names the two usual ones.
@@ -267,7 +267,13 @@ export function LaneGitHeader({
// keyboard or a touchscreen has. // keyboard or a touchscreen has.
onFocus={openFiles} onFocus={openFiles}
onBlur={closeFiles} 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" 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 })} {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> = { const STATE_CLASS: Record<LaneNode["state"], string> = {
done: "border-status-success/60 text-status-success bg-status-success/10", 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", "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", failed: "border-status-danger/60 text-status-danger bg-status-danger/10",
pending: "border-border-light text-fg-muted bg-transparent", 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, * 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 * 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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api"; import { api, type RunHandle } from "../../lib/api";
import type { Session } from "../../lib/types"; import type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu"; 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({ export function SessionMenu({
laneId, laneId,
@@ -39,7 +41,6 @@ export function SessionMenu({
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`; const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
const pick = (fn: () => unknown) => () => { const pick = (fn: () => unknown) => () => {
@@ -48,12 +49,21 @@ export function SessionMenu({
}; };
const closeTab = async () => { const closeTab = async () => {
if (!run) return; 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 { try {
await api.run.kill(run.id); await api.run.kill(run.id);
onClose();
} catch (err) { } 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}`))}> <MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
{t("sessionMenu.details")} {t("sessionMenu.details")}
</MenuItem> </MenuItem>
<MenuItem onSelect={pick(() => void navigator.clipboard.writeText(session.id))}> <MenuItem onSelect={pick(() => void copyText(session.id))}>
{t("sessionMenu.copyId")} {t("sessionMenu.copyId")}
</MenuItem> </MenuItem>
</> </>
@@ -92,11 +102,6 @@ export function SessionMenu({
{t("tabs.close")} {t("tabs.close")}
</MenuItem> </MenuItem>
)} )}
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</ContextMenu> </ContextMenu>
); );
} }
+128 -52
View File
@@ -2,22 +2,52 @@
* @file SessionReplay — an ENDED session opened in its workspace * @file SessionReplay — an ENDED session opened in its workspace
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens * (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
* an old one: its conversation, read-only (`ConversationView`), and a prompt * 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 — * box under it (a pasted image rides along as a thumbnail). Opening it resumes
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the * nothing; only SENDING a message does — `POST /api/lanes/:id/start` with
* first free pane slot, after which `onResumed` opens the pane (`&run=`). The * `resumeSessionId`, that prompt and `slot: "auto"` (the server picks the free
* session's tool and folder come from its row (`GET /api/sessions/:id`), so a * tab), after which `onResumed` opens the pane (`&run=`). The panes are re-read
* resume runs on the tool the session ran on (the server enforces that too). * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types"; import type { Lane, Session } from "../../lib/types";
import { ConversationView } from "../conversation/ConversationView"; import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup"; 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({ export function SessionReplay({
lane, lane,
@@ -27,15 +57,18 @@ export function SessionReplay({
}: { }: {
lane: Lane; lane: Lane;
sessionId: string; 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[]; liveRuns: RunHandle[];
/** Opens the pane the resume started in. */ /** Opens the pane the resume started in — of `laneId` when it is another
onResumed: (runId: string) => void; * (nested) workspace's pane that holds the session. */
onResumed: (runId: string, laneId?: number) => void;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]); const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -46,7 +79,7 @@ export function SessionReplay({
setImages([]); setImages([]);
setError(null); setError(null);
api.sessions api.sessions
.get(sessionId) .get(sessionId, { events: false })
.then((r) => live && setSession(r.session)) .then((r) => live && setSession(r.session))
.catch(() => undefined); .catch(() => undefined);
return () => { return () => {
@@ -54,26 +87,32 @@ export function SessionReplay({
}; };
}, [sessionId]); }, [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 send = async () => {
const text = promptWithImages(prompt, images); if (!text || busy || uploading || livePane || !resumable) return;
if (!text || busy) return;
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const r = await api.lanes.action(lane.id, "start", { // Re-read: a tab that took this session since the list was read is
slot, // opened, never resumed a second time.
resumeSessionId: sessionId, const fresh = await api.run
prompt: text, .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(""); setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url)); images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]); 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) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -83,42 +122,79 @@ export function SessionReplay({
return ( return (
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2"> <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"> <div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} /> <ConversationView sessionId={sessionId} />
</div> </div>
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50"> {livePane ? (
<PromptEditor <div
bare data-testid="session-replay-live"
images={images} className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
onImagesChange={setImages} >
value={prompt} <span className="min-w-0 flex-1">{t("replay.live")}</span>
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>
<button <button
type="button" type="button"
data-testid="session-replay-send" onClick={() => onResumed(livePane.id)}
onClick={() => void send()} className="btn-ghost text-xs"
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"
> >
<ArrowUp className="h-4 w-4" /> <SquareTerminal className="h-4 w-4" />
{t("resume.openPane")}
</button> </button>
</div> </div>
{error && ( ) : session && !resumable ? (
<p role="alert" className="mt-1 text-xs text-status-danger"> <div
{error} data-testid="session-replay-no-resume"
</p> className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
)} >
</div> <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> </div>
); );
} }
@@ -7,8 +7,9 @@
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the * (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
* original untouched, and is offered even while the original has a live pane. A * 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 * 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 * starts in the workspace's first free pane slot, picked by the server
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only * (`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 * 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. * 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> * @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 type { Lane, Session } from "../../lib/types";
import { useLanes } from "../../hooks/useLanes"; import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { freeSlot, paneRunId } from "./WorkspaceHome"; import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList"; import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** The workspace of this tool whose folder holds `cwd` most deeply — the /** The workspace of this tool whose folder holds `cwd` most deeply — the
@@ -81,8 +82,8 @@ export function SessionResumeButton({
(same(session.cwd, lane.cwd) || (same(session.cwd, lane.cwd) ||
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null); sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
if (!run && !resumable) return null; if (!run && !resumable) return null;
const open = (runId: string) => const open = (runId: string, laneId = lane.id) =>
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
const resume = async (fork: boolean) => { const resume = async (fork: boolean) => {
// Busy first: the Confirm button is disabled only through it, and the // Busy first: the Confirm button is disabled only through it, and the
@@ -99,17 +100,12 @@ export function SessionResumeButton({
setRuns(fresh); setRuns(fresh);
const live = liveRunFor(lane, fresh, session.id); const live = liveRunFor(lane, fresh, session.id);
if (live && !fork) return open(live.id); if (live && !fork) return open(live.id);
const slot = freeSlot(lane.id, fresh); // A pane of another (nested) workspace may hold it: open it there.
if (slot === null) { const r = await startInFreeTabDetailed(lane.id, {
setError(t("home.allBusy"));
return;
}
const r = await api.lanes.action(lane.id, "start", {
slot,
resumeSessionId: session.id, resumeSessionId: session.id,
...(fork ? { forkSession: true } : {}), ...(fork ? { forkSession: true } : {}),
}); });
open(r.run_id ?? paneRunId(lane.id, slot)); open(r.runId, r.laneId);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
+178 -37
View File
@@ -10,8 +10,9 @@
* router's models, plus Effort); under it the quieter choices — AI Router * router's models, plus Effort); under it the quieter choices — AI Router
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval, * (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 * 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), * 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, * **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 * 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 * Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked * server/lib/lane-sources.js). Folder suggestions are the repo's untracked
@@ -20,6 +21,10 @@
*/ */
import { Checkbox } from "../Checkbox"; 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 { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -64,22 +69,19 @@ import {
liveRunFor, liveRunFor,
SessionFold, SessionFold,
sessionDot, sessionDot,
sessionDotLabel,
sessionWorktreeName, sessionWorktreeName,
useSessionPage, useSessionPage,
} from "./WorkspaceSessionList"; } from "./WorkspaceSessionList";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */ export {
export function paneRunId(laneId: number, slot: number): string { freeSlot,
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`; laneOfRun,
} paneRunId,
startInFreeTab,
/** The first pane slot with no live run, or null when all four are busy. */ startInFreeTabDetailed,
export function freeSlot(laneId: number, runs: RunHandle[]): number | null { } from "./startSession";
const busy = new Set( import { startInFreeTab } from "./startSession";
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;
}
function fmtBytes(n: number | null): string { function fmtBytes(n: number | null): string {
if (!n) return ""; if (!n) return "";
@@ -88,6 +90,9 @@ function fmtBytes(n: number | null): string {
return `${(n / 1024 / 1024).toFixed(1)} MB`; 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)); const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
type HomeProps = Parameters<typeof HomeBody>[0]; type HomeProps = Parameters<typeof HomeBody>[0];
@@ -105,8 +110,9 @@ function HomeBody({
onRenamed, onRenamed,
}: { }: {
lane: Lane; lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */ /** Row-menu items the page carries out (its modals); rename is done here.
onMenuAction: (action: WorkspaceMenuAction) => void; * `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. */ /** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void; onRenamed?: () => void;
}) { }) {
@@ -158,9 +164,17 @@ function HomeBody({
// its own (`claude --worktree`), made and cleaned up by Claude Code. // its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false); const [inWorktree, setInWorktree] = useState(false);
const [starting, setStarting] = 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 [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]); const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [sessions, setSessions] = useState<Session[] | null>(null); 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 [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null); const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | 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 [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null); const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>(); const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>(); const sourceCtx = useContextMenu<LaneSource>();
@@ -186,13 +202,17 @@ function HomeBody({
.then((r) => live && setLiveRuns(r.items)) .then((r) => live && setLiveRuns(r.items))
.catch(() => undefined); .catch(() => undefined);
api.sessions api.sessions
.list({ lane: lane.id, archived: false, limit: 30 }) .list({ lane: lane.id, archived: false, limit: recentLimit })
.then((r) => live && setSessions(r.sessions)) .then((r) => {
if (!live) return;
setSessions(r.sessions);
setSessionTotal(r.total ?? r.sessions.length);
})
.catch(() => live && setSessions([])); .catch(() => live && setSessions([]));
return () => { return () => {
live = false; live = false;
}; };
}, [lane.id, runKey, version]); }, [lane.id, runKey, version, recentLimit]);
const archived = useSessionPage( const archived = useSessionPage(
{ lane: lane.id, archived: true, limit: 30 }, { lane: lane.id, archived: true, limit: 30 },
`${runKey}:${version}` `${runKey}:${version}`
@@ -269,18 +289,17 @@ function HomeBody({
} }
}; };
const text = promptWithImages(prompt, images);
const start = async () => { const start = async () => {
const slot = freeSlot(lane.id, liveRuns); // Enter reaches here past the disabled button: the same guards again.
if (slot === null) { // A ref, not only the state: two Enters in one tick both read `starting` false.
setError(t("home.allBusy")); if (startingRef.current || uploading || !text) return;
return; startingRef.current = true;
}
setStarting(true); setStarting(true);
setError(null); setError(null);
try { try {
const r = await api.lanes.action(lane.id, "start", { const runId = await startInFreeTab(lane.id, {
slot, prompt: text,
prompt: promptWithImages(prompt, images),
// Only a different tool is named: the workspace's own keeps starting // Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings. // even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}), ...(tool !== lane.tool ? { tool } : {}),
@@ -295,15 +314,61 @@ function HomeBody({
setPrompt(""); setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url)); images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]); setImages([]);
const runId = r.run_id ?? paneRunId(lane.id, slot);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) { } catch (err) {
setError(message(err)); setError(message(err));
} finally { } finally {
startingRef.current = false;
setStarting(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 openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id); const r = liveRunFor(lane, liveRuns, s.id);
navigate( navigate(
@@ -364,7 +429,7 @@ function HomeBody({
onBlur={() => setTitle(null)} onBlur={() => setTitle(null)}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Escape") setTitle(null); if (e.key === "Escape") setTitle(null);
if (e.key !== "Enter") return; if (!isSubmitEnter(e)) return;
const next = title.trim(); const next = title.trim();
setTitle(null); setTitle(null);
if (next === (lane.title || "")) return; if (next === (lane.title || "")) return;
@@ -421,6 +486,7 @@ function HomeBody({
fileCwd={lane.cwd} fileCwd={lane.cwd}
images={images} images={images}
onImagesChange={setImages} onImagesChange={setImages}
onBusyChange={setUploading}
bare bare
/> />
<div className="mt-1 flex items-center gap-1"> <div className="mt-1 flex items-center gap-1">
@@ -459,10 +525,10 @@ function HomeBody({
type="button" type="button"
data-testid="workspace-home-start" data-testid="workspace-home-start"
onClick={() => void start()} onClick={() => void start()}
disabled={starting} disabled={starting || uploading || !text}
aria-label={t("home.start")} aria-label={t("home.start")}
title={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" /> <ArrowUp className="h-4 w-4" />
</button> </button>
@@ -582,6 +648,29 @@ function HomeBody({
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60"> <ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
{(sessions ?? []).map((s) => { {(sessions ?? []).map((s) => {
const live = !!liveRunFor(lane, liveRuns, s.id); 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 ( return (
<li key={s.id}> <li key={s.id}>
<button <button
@@ -592,7 +681,11 @@ function HomeBody({
onContextMenu={(e) => recentCtx.open(e, s)} onContextMenu={(e) => recentCtx.open(e, s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3" 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"> <span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)} {s.name || s.id.slice(0, 8)}
</span> </span>
@@ -618,6 +711,16 @@ function HomeBody({
); );
})} })}
</ul> </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"> <div className="mt-2">
<SessionFold <SessionFold
testId={`workspace-archived-${lane.id}`} testId={`workspace-archived-${lane.id}`}
@@ -632,7 +735,11 @@ function HomeBody({
</div> </div>
</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 && ( {view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning"> <p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })} {t("home.unsupported", { tool: aiTools.label(lane.tool) })}
@@ -887,11 +994,11 @@ function HomeBody({
onClick={() => setMemoryOpen(false)} onClick={() => setMemoryOpen(false)}
> >
<div <div
ref={memoryPanel}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("home.memory")} aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()} 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" 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"> <div className="mb-3 flex shrink-0 items-center justify-between">
@@ -919,9 +1026,30 @@ function HomeBody({
key={f.path} key={f.path}
type="button" type="button"
role="tab" role="tab"
id={`workspace-memory-tab-${i}`}
aria-controls="workspace-memory-panel"
tabIndex={i === memoryTab ? 0 : -1}
title={f.path} title={f.path}
aria-selected={i === memoryTab} aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)} 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 ${ className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab i === memoryTab
? "border-accent/30 bg-accent/15 text-accent" ? "border-accent/30 bg-accent/15 text-accent"
@@ -933,7 +1061,12 @@ function HomeBody({
))} ))}
</div> </div>
{memory[memoryTab] && ( {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} /> <MarkdownPreview text={memory[memoryTab].content} />
</div> </div>
)} )}
@@ -949,6 +1082,12 @@ function HomeBody({
session={recentCtx.menu.item} session={recentCtx.menu.item}
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)} run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
onClose={recentCtx.close} onClose={recentCtx.close}
onRename={() => {
const s = recentCtx.menu!.item;
setNameDraft(s.name || "");
setRenamingId(s.id);
}}
onArchive={() => void archiveSession(recentCtx.menu!.item)}
/> />
)} )}
{sourceCtx.menu && ( {sourceCtx.menu && (
@@ -960,7 +1099,7 @@ function HomeBody({
> >
<MenuItem <MenuItem
onSelect={() => { onSelect={() => {
void navigator.clipboard.writeText(sourceCtx.menu!.item.path); void copyText(sourceCtx.menu!.item.path);
sourceCtx.close(); sourceCtx.close();
}} }}
> >
@@ -985,7 +1124,9 @@ function HomeBody({
x={menuAt.x} x={menuAt.x}
y={menuAt.y} y={menuAt.y}
onClose={() => setMenuAt(null)} onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))} onAction={(a, runId) =>
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
}
/> />
)} )}
</div> </div>
+38 -8
View File
@@ -6,7 +6,9 @@
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on * session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer * right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to * 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> * @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 { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react"; import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types"; 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. */ /** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
export function workspaceDot(lane: Lane): string { 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.liveness === "dead" || lane.status === "error") return "bg-status-danger";
if (lane.status === "running") return "bg-status-success animate-pulse-slow"; if (lane.status === "running") return "bg-status-success animate-pulse-slow";
if (lane.liveness === "active") return "bg-status-success"; 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 { export function workspaceName(lane: Lane): string {
@@ -78,19 +96,23 @@ export function WorkspaceRow({
draggable={!renaming} draggable={!renaming}
onDragStart={(e) => { onDragStart={(e) => {
// Firefox starts no drag at all without a setData call. // 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"; e.dataTransfer.effectAllowed = "move";
}} }}
onDragOver={(e) => { onDragOver={(e) => {
if (!isLaneDrag(e)) return;
e.preventDefault(); e.preventDefault();
setDropTarget(true); setDropTarget(true);
}} }}
onDragLeave={() => setDropTarget(false)} onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)} onDragEnd={() => setDropTarget(false)}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault();
setDropTarget(false); 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); if (Number.isInteger(from)) onReorder(from, index);
}} }}
onContextMenu={openMenu} onContextMenu={openMenu}
@@ -125,7 +147,7 @@ export function WorkspaceRow({
// Blur CANCELS — Enter is the only save, so one edit fires one PATCH. // Blur CANCELS — Enter is the only save, so one edit fires one PATCH.
onBlur={() => onRenameEnd(null)} onBlur={() => onRenameEnd(null)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft.trim()); if (isSubmitEnter(e)) onRenameEnd(draft.trim());
if (e.key === "Escape") onRenameEnd(null); 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" 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" type="button"
data-testid={`workspace-open-${lane.id}`} data-testid={`workspace-open-${lane.id}`}
aria-current={selected ? "true" : undefined} 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={() => { onDoubleClick={() => {
setDraft(lane.title || ""); setDraft(lane.title || "");
onRenameStart(); onRenameStart();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
setDraft(lane.title || "");
onRenameStart();
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return; if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault(); e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1)); onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
@@ -148,7 +177,8 @@ export function WorkspaceRow({
title={`${lane.cwd}\n${t("row.hint")}`} title={`${lane.cwd}\n${t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left" 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> <span className="truncate text-[13px] text-fg-primary">{name}</span>
</button> </button>
)} )}
@@ -1,7 +1,10 @@
/** /**
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at * @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 * 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), * Rename (the row's inline field), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a * 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 * 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> * @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 { useTranslation } from "react-i18next";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu"; 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"; export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
@@ -29,37 +36,70 @@ export function WorkspaceRowMenu({
x: number; x: number;
y: number; y: number;
onClose: () => void; onClose: () => void;
/** Items the tree itself carries out (rename field, modals, selection). */ /** Items the tree itself carries out (rename field, modals, selection);
onAction: (action: WorkspaceMenuAction) => void; * `started` comes with the run it opened. */
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
}) { }) {
const { t } = useTranslation("lanes"); 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) => { const stop = async () => {
setError(null); 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 { try {
await fn(); await api.lanes.action(lane.id, "stop");
if (then) onAction(then);
onClose();
} catch (err) { } 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 ( return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}> <ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem <MenuItem onSelect={() => void (live ? stop() : start())}>
onSelect={() =>
live
? void run(() => api.lanes.action(lane.id, "stop"))
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
}
>
{live ? t("menu.stop") : t("menu.start")} {live ? t("menu.stop") : t("menu.start")}
</MenuItem> </MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem> <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</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")} {t("menu.copyPath")}
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
@@ -71,11 +111,6 @@ export function WorkspaceRowMenu({
<MenuItem danger onSelect={() => onAction("remove")}> <MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")} {t("menu.remove")}
</MenuItem> </MenuItem>
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</ContextMenu> </ContextMenu>
); );
} }
@@ -17,10 +17,16 @@
* any other session opens in its workspace too (`?lane=<id>&session=<id>`, * any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a * `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch } from "lucide-react"; import { Archive, GitBranch } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; 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 /** 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 { export function sessionDot(session: Session, live: boolean): string {
if (isSessionAwaitingInput(session)) return "bg-status-warning"; if (isSessionAwaitingInput(session)) return "bg-status-warning";
if (session.status === "error") return "bg-status-danger"; 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"; 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 }; export type SessionPage = { sessions: Session[]; total: number };
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */ /** 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)} onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
> >
<Link <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)}`} 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" 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 [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>(); const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>(); 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 // 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. // 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 const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
.map((r) => `${r.id}=${r.sessionId ?? ""}`) .map((r) => `${r.id}=${r.sessionId ?? ""}`)
.join(",")}`; .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 sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter( 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 // 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 // top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match. // 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 = const fresh =
sessions === null || q 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; const count = sessions === null ? null : shown.length;
useEffect(() => { useEffect(() => {
if (count !== null) onMatches?.(count); if (count !== null) onMatches?.(count);
@@ -274,10 +327,21 @@ export function WorkspaceSessionList({
setDraft(s.name || ""); setDraft(s.name || "");
setRenaming(s.id); setRenaming(s.id);
}; };
const archive = (s: Session) => const archive = async (s: Session) => {
sessionWorktreeName(lane, s) && !runFor(s) if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
? setArchiving(s) // A live one keeps running in its tab; archiving only hides its row.
: void patch(s, { archived: true }); 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) => { const archiveWithWorktree = async (s: Session) => {
setBusy(true); setBusy(true);
setError(null); setError(null);
@@ -347,7 +411,8 @@ export function WorkspaceSessionList({
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" : "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> <span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon <AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }} tool={{ id: r.tool, label: tools.label(r.tool) }}
@@ -373,7 +438,7 @@ export function WorkspaceSessionList({
onBlur={() => setRenaming(null)} onBlur={() => setRenaming(null)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null); if (e.key === "Escape") setRenaming(null);
if (e.key !== "Enter") return; if (!isSubmitEnter(e)) return;
setRenaming(null); setRenaming(null);
const name = draft.trim(); const name = draft.trim();
if (name && name !== s.name) void patch(s, { name }); if (name && name !== s.name) void patch(s, { name });
@@ -389,7 +454,10 @@ export function WorkspaceSessionList({
type="button" type="button"
data-testid={`workspace-session-${s.id}`} data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined} 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( navigate(
run run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}` ? `/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 // Ctrl/Cmd+click a live one: open it in a split beside the
// focused pane (Workspace reads this state, not the URL). // focused pane (Workspace reads this state, not the URL).
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
) );
} }}
onDoubleClick={() => startRename(s)} onDoubleClick={() => startRename(s)}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
startRename(s);
}
}}
onContextMenu={(e) => sessionCtx.open(e, s)} onContextMenu={(e) => sessionCtx.open(e, s)}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${ title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s) sessionWorktreeName(lane, s)
@@ -414,8 +488,10 @@ export function WorkspaceSessionList({
> >
<span <span
data-testid={`workspace-session-dot-${s.id}`} data-testid={`workspace-session-dot-${s.id}`}
aria-hidden
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`} 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> <span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{sessionWorktreeName(lane, s) && ( {sessionWorktreeName(lane, s) && (
<GitBranch <GitBranch
@@ -432,10 +508,12 @@ export function WorkspaceSessionList({
<button <button
type="button" type="button"
data-testid={`workspace-session-archive-${s.id}`} data-testid={`workspace-session-archive-${s.id}`}
onClick={() => archive(s)} onClick={() => void archive(s)}
title={t("tree.archiveSession")} title={t("tree.archiveSession")}
aria-label={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" /> <Archive className="h-3.5 w-3.5" />
</button> </button>
@@ -443,6 +521,16 @@ export function WorkspaceSessionList({
); );
})} })}
</ul> </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} {failure}
{confirmArchive} {confirmArchive}
{ctx.menu && ( {ctx.menu && (
@@ -464,7 +552,7 @@ export function WorkspaceSessionList({
run={runFor(sessionCtx.menu.item)} run={runFor(sessionCtx.menu.item)}
onClose={sessionCtx.close} onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)} 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 userEvent from "@testing-library/user-event";
import { useState } from "react"; import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel"; import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types"; 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", () => ({ vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane), gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { api: {
@@ -134,7 +140,13 @@ beforeEach(() => {
/** The Workspace right panel's Files/Changes activity-bar buttons, around the /** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */ * 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"); const [tab, setTab] = useState<"files" | "changes">("changes");
return ( return (
<> <>
@@ -144,7 +156,7 @@ function Tabbed({ laneId }: { laneId: number }) {
<button type="button" onClick={() => setTab("changes")}> <button type="button" onClick={() => setTab("changes")}>
Changes Changes
</button> </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(); expect(screen.getByTestId("lane-file-save")).toBeDisabled();
await user.type(editor, "x"); await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled(); 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" })); await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;"); expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.queryByTestId("lane-file-editor")).toBeNull();
@@ -739,6 +756,33 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); 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 () => { it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({ vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true, available: true,
@@ -16,6 +16,8 @@ import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types"; 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", () => ({ vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane), gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } }, api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
@@ -371,7 +373,6 @@ describe("git login (issue #45)", () => {
describe("merge (issue #45)", () => { describe("merge (issue #45)", () => {
it("merges the picked branch into the current one after a confirm", async () => { 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(); const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />); render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ })); await user.click(screen.getByRole("button", { name: /Branch/ }));
@@ -380,11 +381,9 @@ describe("merge (issue #45)", () => {
await waitFor(() => await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" }) expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
); );
confirm.mockRestore();
}); });
it("offers to merge the upstream when the branch has diverged", async () => { it("offers to merge the upstream when the branch has diverged", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />); render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" })); await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
@@ -2,14 +2,16 @@
* @file SessionReplay.test.tsx * @file SessionReplay.test.tsx
* @description An ended session opened in its workspace: its conversation and * @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 * 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 * (`resumeSessionId` + the prompt, in the free tab the server picks), after
* the pane opens. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane } from "../../../lib/types"; import type { Lane } from "../../../lib/types";
import type { RunHandle } from "../../../lib/api"; 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)} /> <textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
), ),
})); }));
vi.mock("../WorkspaceHome", () => ({ vi.mock("../startSession", () => ({
freeSlot: (_id: number, runs: RunHandle[]) => (runs.length ? 1 : 0), startInFreeTabDetailed: vi.fn(async () => ({
paneRunId: (id: number, slot: number) => runId: "agenthub-lane-1-1",
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`, laneId: 1,
held: false,
})),
})); }));
vi.mock("../../../lib/panePaste", () => ({ pasteToRun: vi.fn(() => true) }));
vi.mock("../SessionResumeButton", () => ({ SessionResumeButton: () => null }));
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
api: { api: {
sessions: { sessions: {
get: vi.fn(async () => ({ session: { id: "s-old", tool: "claude", cwd: "/r" } })), 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 { api } from "../../../lib/api";
import { startInFreeTabDetailed as startInFreeTab } from "../startSession";
import { SessionReplay } from "../SessionReplay"; import { SessionReplay } from "../SessionReplay";
import { pasteToRun } from "../../../lib/panePaste";
const lane = { id: 1, cwd: "/r", tool: "claude" } as unknown as Lane; 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 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()); beforeEach(() => vi.clearAllMocks());
const renderIn = (ui: React.ReactElement) => render(<MemoryRouter>{ui}</MemoryRouter>);
describe("SessionReplay", () => { describe("SessionReplay", () => {
it("shows the conversation and resumes nothing until a message is sent", async () => { it("shows the conversation and resumes nothing until a message is sent", async () => {
const onResumed = vi.fn(); const onResumed = vi.fn();
const user = userEvent.setup(); 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"); 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(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.type(screen.getByLabelText("Message to continue this session…"), "carry on");
await user.click(screen.getByTestId("session-replay-send")); await user.click(screen.getByTestId("session-replay-send"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { // The server picks the free tab (`slot: "auto"`, inside startInFreeTab).
slot: 1, await waitFor(() =>
resumeSessionId: "s-old", expect(startInFreeTab).toHaveBeenCalledWith(1, {
prompt: "carry on", 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 * @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, * 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 * 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 * the free pane slot the server picks (`slot: "auto"`) and opens that pane —
* busy; a Fork asks too and starts a new session id beside a live original. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -81,7 +81,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript"); expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript");
await user.click(screen.getByRole("button", { name: "Resume session" })); await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1, slot: "auto",
resumeSessionId: "s-1", resumeSessionId: "s-1",
}); });
await waitFor(() => await waitFor(() =>
@@ -97,7 +97,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" })); await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" })); await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(4, "start", { expect(api.lanes.action).toHaveBeenCalledWith(4, "start", {
slot: 0, slot: "auto",
resumeSessionId: "s-2", resumeSessionId: "s-2",
}); });
}); });
@@ -135,7 +135,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session"); expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
await user.click(screen.getByRole("button", { name: "Fork session" })); await user.click(screen.getByRole("button", { name: "Fork session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1, slot: "auto",
resumeSessionId: "s-1", resumeSessionId: "s-1",
forkSession: true, forkSession: true,
}); });
@@ -147,7 +147,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" })); await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" })); await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1, slot: "auto",
resumeSessionId: "s-wt", resumeSessionId: "s-wt",
}); });
}); });
@@ -178,7 +178,12 @@ describe("SessionResumeButton", () => {
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle (slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
); );
await user.click(screen.getByRole("button", { name: "Resume session" })); 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 () => { 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"); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
}); });
it("refuses when all four panes are busy", async () => { it("says so when the server finds all four panes busy", async () => {
runs = [0, 1, 2, 3].map( vi.mocked(api.lanes.action).mockRejectedValueOnce(
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
); );
const user = userEvent.setup(); const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" })); await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" })); await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes"); expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
expect(api.lanes.action).not.toHaveBeenCalled(); });
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 () => { it("shows nothing outside a workspace or for a non-Claude session", async () => {
@@ -24,13 +24,24 @@ vi.mock("../../run/RunSetup", () => ({
PromptEditor: ({ PromptEditor: ({
value, value,
onChange, onChange,
onSubmit,
placeholder, placeholder,
}: { }: {
value: string; value: string;
onChange: (s: string) => void; onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string; 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: ({ ModelMenu: ({
value, value,
@@ -199,7 +210,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login"); await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
await user.click(screen.getByTestId("workspace-home-start")); await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1, slot: "auto",
prompt: "fix the login", prompt: "fix the login",
permissionMode: "auto", permissionMode: "auto",
}); });
@@ -248,7 +259,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "port it"); await user.type(screen.getByLabelText("How can I help you today?"), "port it");
await user.click(screen.getByTestId("workspace-home-start")); await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1, slot: "auto",
prompt: "port it", prompt: "port it",
tool: "codex", tool: "codex",
sandbox: "workspace-write", sandbox: "workspace-write",
@@ -300,23 +311,40 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "try it"); await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start")); await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1, slot: "auto",
prompt: "try it", prompt: "try it",
permissionMode: "auto", permissionMode: "auto",
worktree: true, worktree: true,
}); });
}); });
it("refuses to start when all four panes are busy", async () => { it("says so when the server finds all four panes busy", async () => {
runs = [0, 1, 2, 3].map( vi.mocked(api.lanes.action).mockRejectedValueOnce(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
); );
const user = userEvent.setup(); const user = userEvent.setup();
renderHome(); renderHome();
await screen.findByTestId("workspace-recent-s-old"); 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")); 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(); 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 () => { 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 * @file ConsoleTabs.tsx
* @description The Workspace session view's tab bar: one tab per live tmux * @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 * 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 * `?run=`. A tab is named by its session (`sessions.name`, double-click or
* opens a new tab in the first free slot — a plain shell * 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 * (`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 * 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 * 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> * @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 { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react"; import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { RunHandle } 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 { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { freeSlot } from "../lanes/WorkspaceHome"; import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { RunIcon, startPaneDrag } from "./PaneGrid"; import { RunIcon, startPaneDrag } from "./PaneGrid";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>; 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 /** The tools a new tab or split offers: the workspace's own whatever its
* state, then every installed, enabled one. */ * state, then every installed, enabled one. */
export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] { export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
@@ -63,10 +122,16 @@ export function ConsoleTabs({
onClosed, onClosed,
onHome, onHome,
actions, actions,
sessions,
onRenamed,
}: { }: {
lane: Lane; lane: Lane;
/** The workspace's live runs, in slot order. */ /** The workspace's live runs, in slot order. */
tabs: RunHandle[]; 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. */ /** The focused pane's run. */
activeRunId: string | null; activeRunId: string | null;
/** Runs the other panes show. */ /** Runs the other panes show. */
@@ -84,7 +149,10 @@ export function ConsoleTabs({
const navigate = useNavigate(); const navigate = useNavigate();
const aiTools = useAiTools(); const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false); 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 menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<RunHandle>(); const tabCtx = useContextMenu<RunHandle>();
@@ -103,31 +171,82 @@ export function ConsoleTabs({
}, [menuOpen]); }, [menuOpen]);
const slot = freeSlot(lane.id, tabs); 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 toolIds = newTabTools(lane, aiTools);
const open = async (body: Record<string, unknown>, split = false) => { const open = async (body: Record<string, unknown>, split = false) => {
setMenuOpen(false); setMenuOpen(false);
if (slot === null) return; if (slot === null) return;
setError(null);
try { try {
const r = await api.lanes.action(lane.id, "start", { slot, ...body }); onOpen(await startInFreeTab(lane.id, body), split);
if (r.run_id) onOpen(r.run_id, split);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); toast.error(err);
} }
}; };
const close = async (runId: string) => { // An AI tab may be mid-turn: stopping it asks first. A shell holds no agent.
setError(null); 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 { try {
await api.run.kill(runId); await api.run.kill(r.id);
onClosed(runId); onClosed(r.id);
} catch (err) { } 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. // A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (r: RunHandle) => unknown) => () => { const pickTab = (fn: (r: RunHandle) => unknown) => () => {
const r = tabCtx.menu?.item; const r = tabCtx.menu?.item;
@@ -153,9 +272,32 @@ export function ConsoleTabs({
</button> </button>
)} )}
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch"> <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 active = r.id === activeRunId;
const shown = !active && shownRunIds.includes(r.id); 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 ( return (
<div <div
key={r.id} key={r.id}
@@ -182,18 +324,34 @@ export function ConsoleTabs({
type="button" type="button"
role="tab" role="tab"
aria-selected={active} 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}`} data-testid={`console-tab-${r.slot ?? 0}`}
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)} 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" 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> <span className="truncate">{label(r)}</span>
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />}
</button> </button>
<button <button
type="button" type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`} data-testid={`console-tab-close-${r.slot ?? 0}`}
onClick={() => void close(r.id)} onClick={() => void close(r)}
aria-label={t("tabs.close")} aria-label={t("tabs.close")}
title={t("tabs.close")} title={t("tabs.close")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${ 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>
)} )}
</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>} {actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
{tabCtx.menu && ( {tabCtx.menu && (
<ContextMenu <ContextMenu
@@ -279,20 +432,19 @@ export function ConsoleTabs({
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}> <MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
{t("sessionMenu.openSplit")} {t("sessionMenu.openSplit")}
</MenuItem> </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}`))}> <MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
{t("sessionMenu.details")} {t("sessionMenu.details")}
</MenuItem> </MenuItem>
<MenuItem <MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}>
onSelect={pickTab((r) => void navigator.clipboard.writeText(r.sessionId ?? ""))}
>
{t("sessionMenu.copyId")} {t("sessionMenu.copyId")}
</MenuItem> </MenuItem>
</> </>
)} )}
<MenuSeparator /> <MenuSeparator />
<MenuItem danger onSelect={pickTab((r) => void close(r.id))}> <MenuItem danger onSelect={pickTab((r) => void close(r))}>
{t("tabs.close")} {t("tabs.close")}
</MenuItem> </MenuItem>
</ContextMenu> </ContextMenu>
@@ -102,7 +102,12 @@ export function LaneConsolePane({
autoAttachedForLane.current = laneId; autoAttachedForLane.current = laneId;
api.run api.run
.get(attachRequest.runId) .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( .catch(
(err: unknown) => (err: unknown) =>
live && live &&
@@ -136,7 +141,7 @@ export function LaneConsolePane({
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-3"> <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". */} {/* Not probed yet (a tool the page has not probed, or a shell) is not "missing". */}
{binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && ( {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" /> <AlertCircle className="w-4 h-4 flex-shrink-0" />
<span> <span>
{t("binary.missing", { {t("binary.missing", {
@@ -148,7 +153,7 @@ export function LaneConsolePane({
)} )}
{error && ( {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" /> <AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span> <span className="flex-1 break-all">{error}</span>
</div> </div>
@@ -157,7 +162,7 @@ export function LaneConsolePane({
{!handle ? ( {!handle ? (
<div <div
data-testid="pane-no-run" 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> <p>{t("emptyPane")}</p>
{onNewSession && ( {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 * 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 * 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 * 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 * 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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; 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 type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { import {
@@ -37,6 +49,7 @@ import {
swapInLayout, swapInLayout,
} from "../../lib/paneLayout"; } from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane"; export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */ /** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -283,8 +296,11 @@ export function PaneGrid({
tools, tools,
toolLabel, toolLabel,
onNewSplit, onNewSplit,
stateOf,
}: { }: {
layout: PaneLayout; 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. */ /** Every slot to keep mounted; those not in the layout are hidden. */
mounted: number[]; mounted: number[];
/** The workspace's live runs (its tabs). */ /** 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 /> <GripVertical className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
{run && <RunIcon run={run} label={label(run)} />} {run && <RunIcon run={run} label={label(run)} />}
<span className="min-w-0 truncate">{run ? label(run) : t("panes.empty")}</span> <span className="min-w-0 truncate">{run ? label(run) : t("panes.empty")}</span>
{run && stateOf?.(run) && <RunDot state={stateOf(run)!} />}
<PaneMenu <PaneMenu
testId={`pane-pick-${slot}`} testId={`pane-pick-${slot}`}
label={t("panes.pick")} label={t("panes.pick")}
@@ -482,14 +499,14 @@ export function PaneGrid({
onClick={() => onChange((l) => closeInLayout(l, slot))} 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" 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> </button>
</div> </div>
</div> </div>
)} )}
<div className="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5"> {/* No padding here: the terminal paints its own gutter in its
{renderPane(slot)} palette, so a dark pane never sits in a light-page frame. */}
</div> <div className="flex min-h-0 flex-1 flex-col">{renderPane(slot)}</div>
</div> </div>
); );
})} })}
@@ -550,23 +567,31 @@ export function PaneGrid({
); );
} }
/** A split's divider: a 1px line with a wider grab area. */ /** A split's divider: a 1px line with a wider grab area. Also the Changes
function Divider({ * panel's splitter (Workspace.tsx), over the whole row (`box` = 0,0,1,1). */
export function Divider({
gridRef, gridRef,
dir, dir,
at, at,
box, box,
onRatio, onRatio,
label,
}: { }: {
gridRef: React.RefObject<HTMLDivElement>; gridRef: React.RefObject<HTMLDivElement>;
dir: SplitDir; dir: SplitDir;
at: number; at: number;
box: Rect; box: Rect;
onRatio: (ratio: number) => void; onRatio: (ratio: number) => void;
/** Accessible name; the panes' own by default. */
label?: string;
}) { }) {
const { t } = useTranslation("lanes"); 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 row = dir === "row";
const pos = live ?? at;
const ratioAt = (e: React.PointerEvent) => { const ratioAt = (e: React.PointerEvent) => {
const g = gridRef.current?.getBoundingClientRect(); const g = gridRef.current?.getBoundingClientRect();
if (!g || !g.width || !g.height) return null; if (!g || !g.width || !g.height) return null;
@@ -575,17 +600,22 @@ function Divider({
: ((e.clientY - g.top) / g.height - box.y) / box.h; : ((e.clientY - g.top) / g.height - box.y) / box.h;
}; };
const style = row const style = row
? { left: `${(box.x + box.w * at) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` } ? { left: `${(box.x + box.w * pos) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
: { top: `${(box.y + box.h * at) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 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 ( return (
<div <div
role="separator" role="separator"
tabIndex={0} tabIndex={0}
aria-orientation={row ? "vertical" : "horizontal"} aria-orientation={row ? "vertical" : "horizontal"}
aria-valuenow={Math.round(at * 100)} aria-valuenow={Math.round(pos * 100)}
aria-valuemin={10} aria-valuemin={10}
aria-valuemax={90} aria-valuemax={90}
aria-label={t("panes.resize")} aria-label={label ?? t("panes.resize")}
className={`group absolute z-10 flex justify-center outline-none ${ className={`group absolute z-10 flex justify-center outline-none ${
row row
? "w-2 -translate-x-1/2 cursor-col-resize" ? "w-2 -translate-x-1/2 cursor-col-resize"
@@ -595,15 +625,15 @@ function Divider({
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId); e.currentTarget.setPointerCapture?.(e.pointerId);
setDragging(true); setLive(at);
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
if (!dragging) return; if (!dragging) return;
const r = ratioAt(e); const r = ratioAt(e);
if (r !== null) onRatio(r); if (r !== null) setLive(clamp(r));
}} }}
onPointerUp={() => setDragging(false)} onPointerUp={finish}
onPointerCancel={() => setDragging(false)} onPointerCancel={() => setLive(null)}
onKeyDown={(e) => { onKeyDown={(e) => {
const back = row ? "ArrowLeft" : "ArrowUp"; const back = row ? "ArrowLeft" : "ArrowUp";
const fwd = row ? "ArrowRight" : "ArrowDown"; 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 type { LaneTool } from "../../lib/types";
import { Select } from "../Select"; import { Select } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu"; import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
@@ -131,6 +132,9 @@ interface PromptEditorProps {
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>; onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */ /** Borderless, for a composer box that draws its own frame. */
bare?: boolean; 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. */ /** An attached image: its path on the server's machine and a local preview. */
@@ -153,6 +157,7 @@ export function PromptEditor({
images, images,
onImagesChange, onImagesChange,
bare, bare,
onBusyChange,
}: PromptEditorProps) { }: PromptEditorProps) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null); const taRef = useRef<HTMLTextAreaElement | null>(null);
@@ -265,6 +270,10 @@ export function PromptEditor({
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null); const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [viewing, setViewing] = useState<PromptImage | null>(null); const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: 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 attachImages = (list: FileList | null | undefined) => {
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/")); const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
if (picked.length === 0) return false; if (picked.length === 0) return false;
@@ -320,10 +329,11 @@ export function PromptEditor({
return; return;
} }
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline. // Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate. // isSubmitEnter guards IME input (Vietnamese/CJK), where Enter commits the
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { // candidate. An image still uploading holds the send.
if (isSubmitEnter(e)) {
e.preventDefault(); e.preventDefault();
onSubmit(); if (!uploading) onSubmit();
} }
}; };
@@ -878,7 +888,7 @@ export function ModelMenu({
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key !== "Enter") return; if (!isSubmitEnter(e)) return;
e.preventDefault(); e.preventDefault();
setCustom(false); setCustom(false);
pick(e.currentTarget.value.trim()); pick(e.currentTarget.value.trim());
+241 -18
View File
@@ -16,13 +16,29 @@
* and how `claude` attaches an image. The pane also registers with * 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 * `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. * 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> * @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 { useTranslation } from "react-i18next";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas"; 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 "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme"; import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme"; import { terminalThemeFor } from "../../lib/terminalTheme";
@@ -30,6 +46,37 @@ import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste"; import { notePaneFocus, registerPane } from "../../lib/panePaste";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly"; 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"); const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
interface TerminalViewProps { interface TerminalViewProps {
@@ -62,7 +109,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
paletteRef.current = palette; paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const tRef = useRef(t);
tRef.current = t;
const [drop, setDrop] = useState<DropState | null>(null); 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 // dragenter/dragleave fire for every child crossed, so count them: the hint
// only goes away when the pointer leaves the terminal itself. // only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0); const dragDepth = useRef(0);
@@ -121,11 +180,22 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
cursorBlink: true, cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace', fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current, theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
}); });
termRef.current = term; termRef.current = term;
const fit = new FitAddon(); const fit = new FitAddon();
term.loadAddon(fit); 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); 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 // 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 // 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 // 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 // here while working in a real terminal. Returning true keeps xterm's own
// handling; the preventDefault only stops focus from escaping. // 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) => { 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; 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 * (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing. * resize the real tmux pane down to nothing.
*/ */
const sendSize = () => { const sendSizeNow = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return; if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return; if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}; };
// 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 retryTimer: ReturnType<typeof setTimeout> | null = null;
let ended = false; // server said the pane exited — nothing left to attach to let ended = false; // server said the pane exited — nothing left to attach to
let disposed = false; let disposed = false;
@@ -219,7 +325,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
sock.binaryType = "arraybuffer"; sock.binaryType = "arraybuffer";
sock.onopen = () => { sock.onopen = () => {
sendSize(); setReconnecting(false);
sendSizeNow();
}; };
sock.onmessage = (event) => { sock.onmessage = (event) => {
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]"; 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); const msg = JSON.parse(data);
if (msg.type === "exit") { if (msg.type === "exit") {
ended = true; 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?.(); onExitRef.current?.();
} }
return; return;
@@ -251,7 +358,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
sock.onclose = () => { sock.onclose = () => {
if (disposed || ended) return; if (disposed || ended) return;
// tmux keeps the session alive across a server restart, so re-attaching // 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); 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 // (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 // 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. // 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); term.textarea?.addEventListener("focus", reassert);
const refit = () => { const refit = () => {
@@ -296,6 +405,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
return () => { return () => {
disposed = true; disposed = true;
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
if (sizeTimer) clearTimeout(sizeTimer);
searchRef.current = null;
resizeObserver.disconnect(); resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded); document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert); 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. // Other parts of the page (a diff-line comment) paste into this pane.
useEffect( useEffect(
() => () =>
registerPane(runId, (text) => { registerPane(
termRef.current?.paste(text); runId,
termRef.current?.focus(); (text) => {
}), termRef.current?.paste(text);
termRef.current?.focus();
},
// On screen: a console hidden with CSS measures 0×0.
() => !!containerRef.current?.offsetWidth
),
[runId] [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 ( return (
<div <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) => { onDragEnter={(e) => {
if (!hasFiles(e)) return; if (!hasFiles(e)) return;
e.preventDefault(); e.preventDefault();
@@ -344,12 +485,94 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
onDrop={(e) => void onDrop(e)} onDrop={(e) => void onDrop(e)}
onFocus={() => notePaneFocus(runId)} onFocus={() => notePaneFocus(runId)}
> >
<div <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
ref={containerRef} <p id={hintId} className="sr-only">
className="h-full w-full" {t("terminal.keyboardHint")}
style={{ backgroundColor: palette.background }} </p>
data-testid="terminal-view" {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 && ( {drop && (
// pointer-events-none: the drop must land on the terminal, not on this. // pointer-events-none: the drop must land on the terminal, not on this.
<div <div
@@ -234,6 +234,60 @@ describe("TerminalView", () => {
expect(preventPlainTab).not.toHaveBeenCalled(); 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", () => { it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after // The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane. // 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 })); 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 // Webfont load and page zoom both move cols without resizing the element
// ResizeObserver watches, which is why onResize is the hook. // ResizeObserver watches, which is why onResize is the hook.
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
@@ -284,7 +338,12 @@ describe("TerminalView", () => {
ws.sent.length = 0; ws.sent.length = 0;
termState.cols = 129; termState.cols = 129;
onResizeHandlers.forEach((fn) => fn()); 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", () => { 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.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.commentSend": "Paste into pane",
"changes.commentCancel": "Cancel", "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.split": "Side-by-side",
"changes.layout.unified": "Unified", "changes.layout.unified": "Unified",
"changes.loading": "Reading the working copy…", "changes.loading": "Reading the working copy…",
@@ -390,7 +390,7 @@
"panes.split.bottom": "Split down", "panes.split.bottom": "Split down",
"panes.full": "4 panes is the most a workspace can show", "panes.full": "4 panes is the most a workspace can show",
"panes.newTerminal": "New terminal", "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", "panes.resize": "Resize panes",
"tabs.home": "Workspace home", "tabs.home": "Workspace home",
"sessionMenu.open": "Open", "sessionMenu.open": "Open",
@@ -401,9 +401,47 @@
"panes.show": "Show {{name}} here", "panes.show": "Show {{name}} here",
"panes.splitNew.right": "Split right with a new terminal", "panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down 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.copyPath": "Copy relative path",
"changes.viewCommit": "View commit", "changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA", "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", "imagesOnly": "Only images can be dropped here",
"remove": "Remove image", "remove": "Remove image",
"view": "View 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.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.commentSend": "Dán vào pane",
"changes.commentCancel": "Huỷ", "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.split": "Hai cột",
"changes.layout.unified": "Một cột", "changes.layout.unified": "Một cột",
"changes.loading": "Đang đọc working copy…", "changes.loading": "Đang đọc working copy…",
@@ -390,7 +390,7 @@
"panes.split.bottom": "Tách xuống dưới", "panes.split.bottom": "Tách xuống dưới",
"panes.full": "Một workspace hiện tối đa 4 pane", "panes.full": "Một workspace hiện tối đa 4 pane",
"panes.newTerminal": "Terminal mới", "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", "panes.resize": "Đổi kích thước pane",
"tabs.home": "Trang chủ workspace", "tabs.home": "Trang chủ workspace",
"sessionMenu.open": "Mở", "sessionMenu.open": "Mở",
@@ -401,9 +401,47 @@
"panes.show": "Hiện {{name}} ở đây", "panes.show": "Hiện {{name}} ở đây",
"panes.splitNew.right": "Chia sang phải với terminal mới", "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.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.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit", "changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của 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", "approvalOnRequest": "Khi cần",
"cwd": "Thư mục làm việc", "cwd": "Thư mục làm việc",
"cwdGroups": { "cwdGroups": {
"dashboard": "Dashboard server", "dashboard": "Máy chủ dashboard",
"home": "Home", "home": "Home",
"repo": "Repo nguồn", "repo": "Repo nguồn",
"recent": "Gần đây - Claude Code đã dùng" "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)", "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", "bypass": "Bỏ qua xin quyền cho lần chạy này",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)", "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)", "permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt", "prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…", "promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
@@ -92,7 +92,7 @@
"footer": { "footer": {
"cost": "Chi phí", "cost": "Chi phí",
"duration": "Thời lượng", "duration": "Thời lượng",
"exitCode": "Exit code", "exitCode": "Mã thoát",
"sessionId": "Session ID", "sessionId": "Session ID",
"turns": "Lượt" "turns": "Lượt"
}, },
@@ -134,7 +134,7 @@
"session": "Session" "session": "Session"
}, },
"tokens": { "tokens": {
"cacheRead": "Cache hit", "cacheRead": "Trúng cache",
"cost": "Chi phí", "cost": "Chi phí",
"input": "Input", "input": "Input",
"label": "Context", "label": "Context",
@@ -147,5 +147,17 @@
"imagesOnly": "Chỉ thả được ảnh vào đây", "imagesOnly": "Chỉ thả được ảnh vào đây",
"remove": "Bỏ ảnh", "remove": "Bỏ ảnh",
"view": "Xem ả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 * @file panePaste.test.ts
* @description Pins where a pasted diff comment lands: the workspace's pane * @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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -16,7 +17,7 @@ describe("panePaste", () => {
const off12 = reg("agenthub-lane-12"); const off12 = reg("agenthub-lane-12");
const off0 = reg("agenthub-lane-1"); const off0 = reg("agenthub-lane-1");
const off2 = reg("agenthub-lane-1-2"); 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"]); expect(got["agenthub-lane-1"]).toEqual(["a"]);
notePaneFocus("agenthub-lane-1-2"); notePaneFocus("agenthub-lane-1-2");
pasteToLane(1, "b"); pasteToLane(1, "b");
@@ -27,7 +28,24 @@ describe("panePaste", () => {
expect(got["agenthub-lane-12"]).toBeUndefined(); expect(got["agenthub-lane-12"]).toBeUndefined();
off0(); off0();
off2(); off2();
expect(pasteToLane(1, "d")).toBe(false); expect(pasteToLane(1, "d")).toBeNull();
off12(); 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 * 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 — * 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 * 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> * @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; let lastFocused: string | null = null;
/** Registers a mounted terminal; returns its unregister. */ /** Registers a mounted terminal (`visible`: is it on screen now); returns its
export function registerPane(runId: string, paste: (text: string) => void): () => void { * unregister. */
panes.set(runId, paste); export function registerPane(
runId: string,
paste: (text: string) => void,
visible: () => boolean = () => true
): () => void {
const entry = { paste, visible };
panes.set(runId, entry);
return () => { return () => {
if (panes.get(runId) === paste) panes.delete(runId); if (panes.get(runId) === entry) panes.delete(runId);
if (lastFocused === runId) lastFocused = null; if (lastFocused === runId) lastFocused = null;
}; };
} }
@@ -30,13 +42,21 @@ function ofLane(runId: string, laneId: number): boolean {
return runId === base || runId.startsWith(`${base}-`); return runId === base || runId.startsWith(`${base}-`);
} }
/** Pastes `text` into one of the workspace's mounted panes; false when none is. */ /** Pastes `text` into the pane of `runId` when it is mounted and on screen;
export function pasteToLane(laneId: number, text: string): boolean { * false otherwise (not mounted yet, or hidden). */
const id = export function pasteToRun(runId: string, text: string): boolean {
lastFocused && panes.has(lastFocused) && ofLane(lastFocused, laneId) const p = panes.get(runId);
? lastFocused if (!p?.visible()) return false;
: [...panes.keys()].find((k) => ofLane(k, laneId)); p.paste(text);
if (!id) return false;
panes.get(id)!(text);
return true; 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 * 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 * 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. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -57,15 +65,30 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } 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 { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes"; import { useLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { ConsoleTabs, newTabBody, newTabTools } from "../components/run/ConsoleTabs"; import {
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid"; ConsoleTabs,
newTabBody,
newTabTools,
runName,
runState,
} from "../components/run/ConsoleTabs";
import {
Divider,
type LayoutUpdate,
PaneGrid,
SplitMenus,
usePaneLayout,
} from "../components/run/PaneGrid";
import { import {
type Edge, type Edge,
type PaneLayout, type PaneLayout,
MAX_PANES,
closeInLayout, closeInLayout,
leaves, leaves,
moveInLayout, moveInLayout,
@@ -74,7 +97,8 @@ import {
pruneLayout, pruneLayout,
} from "../lib/paneLayout"; } from "../lib/paneLayout";
import { useAiTools } from "../hooks/useAiTools"; 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 { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
import PipelineMap from "../components/lanes/PipelineMap"; import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard"; 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 * 30/70 → 50/50 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */ * collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "full"] as const; const CHANGES_SIZES = ["compact", "half", "full"] as const;
type ChangesSize = (typeof CHANGES_SIZES)[number]; type ChangesPreset = (typeof CHANGES_SIZES)[number];
const CHANGES_WIDTH: Record<ChangesSize, string> = { /** 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%]", compact: "w-[30%]",
half: "w-1/2", half: "w-1/2",
// The console beside it is HIDDEN, not unmounted: a live run's terminal // 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 /** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, the full width, then closed. */ * 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, compact: Columns2,
half: Maximize2, half: Maximize2,
full: PanelRightClose, 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 ────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────
@@ -161,8 +196,12 @@ function ConsoleArea({
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null; attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
/** Back to the workspace home, where a new session is started. */ /** Back to the workspace home, where a new session is started. */
onNewSession?: () => void; onNewSession?: () => void;
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus. */ /** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus,
onOpenRun: (runId: string, opts?: { split?: boolean; focusOnly?: boolean }) => void; * `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. */ /** A tab was opened or closed: re-read the run list now. */
onRunsChanged: () => void; onRunsChanged: () => void;
}) { }) {
@@ -195,8 +234,26 @@ function ConsoleArea({
const exited = useRef(new Set<string>()); const exited = useRef(new Set<string>());
const laneRef = useRef(selectedLaneId); const laneRef = useRef(selectedLaneId);
laneRef.current = selectedLaneId; laneRef.current = selectedLaneId;
const [splitError, setSplitError] = useState<string | null>(null); // The sessions the tabs hold — their names and states — re-read when a tab
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool)); // 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 runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`; const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
@@ -293,17 +350,16 @@ function ConsoleArea({
const newSplit = async (target: number, edge: Edge, tool?: string) => { const newSplit = async (target: number, edge: Edge, tool?: string) => {
if (!lane || free === null) return; if (!lane || free === null) return;
const laneId = lane.id; const laneId = lane.id;
const slot = free;
setSplitError(null);
try { try {
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true }; 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(); onRunsChanged();
if (!r.run_id || laneRef.current !== laneId) return; if (laneRef.current !== laneId) return;
pending.current.set(slot, activeRuns); pending.current.set(slot, activeRuns);
change((l) => moveInLayout(l.root ? l : view, slot, target, edge)); change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
} catch (err) { } 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 mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
const focusSlot = view.focus; 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 ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div ref={areaRef} className="flex min-h-0 flex-1 flex-col">
{lane && ( {lane && (
<ConsoleTabs <ConsoleTabs
lane={lane} lane={lane}
@@ -326,8 +417,12 @@ function ConsoleArea({
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)} shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
onOpen={(runId, split) => { onOpen={(runId, split) => {
onRunsChanged(); 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))} onClosed={(runId) => ended(slotOf(runId))}
onHome={onNewSession} onHome={onNewSession}
// While one pane is shown, its split menus (a split pane has its own). // 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 <PaneGrid
layout={view} layout={view}
mounted={mounted} mounted={mounted}
@@ -366,6 +456,7 @@ function ConsoleArea({
tools={splitTools} tools={splitTools}
toolLabel={aiTools.label} toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)} onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
renderPane={(slot) => ( renderPane={(slot) => (
<LaneConsolePane <LaneConsolePane
lanes={lanes} lanes={lanes}
@@ -433,7 +524,10 @@ export function Workspace() {
(prev) => { (prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
// A run belongs to its lane: another lane opens on its home. // 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"); if (id === null) next.delete("lane");
else next.set("lane", String(id)); else next.set("lane", String(id));
return next; return next;
@@ -454,7 +548,10 @@ export function Workspace() {
} }
if (paramId === null) setSelectedLaneId(selectedLaneId); if (paramId === null) setSelectedLaneId(selectedLaneId);
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]); }, [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<{ const [destructive, setDestructive] = useState<{
lane: Lane; lane: Lane;
action: "reset" | "remove"; action: "reset" | "remove";
@@ -516,32 +613,119 @@ export function Workspace() {
/* private mode / disabled storage: the panel just forgets between visits */ /* private mode / disabled storage: the panel just forgets between visits */
} }
}, [changesOpen]); }, [changesOpen]);
// A preset name, or the fraction a splitter drag left (stored as a number).
const [changesSize, setChangesSize] = useState<ChangesSize>(() => { const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
try { try {
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null; const saved = window.localStorage.getItem(CHANGES_SIZE_KEY);
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact"; 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 { } catch {
return "compact"; return "compact";
} }
}); });
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize); window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
} catch { } catch {
/* same as above: a forgotten width, not an error */ /* same as above: a forgotten width, not an error */
} }
}, [changesSize]); }, [changesSize]);
const changesFull = changesSize === "full";
const changesPreset = changesStep(changesSize);
const nextChangesSize = const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!; CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel = const changesNextLabel = changesFull
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1] ? tLanes("changes.collapse")
? tLanes("changes.collapse") : tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
: 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 // 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 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 // 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 // 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. // editor: no page padding, no cards, dividers between its regions.
const sessionView = !!currentLane && !home; const sessionView = !!currentLane && !home;
const viewportLocked = const viewportLocked =
sessionView || sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full")));
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => { const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null); setLaneActionError(null);
@@ -807,7 +990,7 @@ export function Workspace() {
<WorkspaceHome <WorkspaceHome
lane={currentLane} lane={currentLane}
onRenamed={() => void refreshLanes()} onRenamed={() => void refreshLanes()}
onMenuAction={(action) => { onMenuAction={(action, runId) => {
if (action === "new-here") { if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd }); setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true); setAddLaneOpen(true);
@@ -816,17 +999,17 @@ export function Workspace() {
} else if (action === "started") { } else if (action === "started") {
setSearchParams({ setSearchParams({
lane: String(currentLane.id), 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 <section
data-testid="lane-detail" data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col${ 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 {/* No header row: the console's tab bar is the header. Only a
@@ -840,7 +1023,7 @@ export function Workspace() {
<select <select
data-testid="pipeline-picker" data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")} 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} value={currentLane.pipeline}
disabled={!!viewedFeature} disabled={!!viewedFeature}
title={ title={
@@ -873,7 +1056,7 @@ export function Workspace() {
{features.length > 0 && ( {features.length > 0 && (
<select <select
data-testid="feature-picker" 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 ?? ""} value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)} onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
> >
@@ -966,8 +1149,8 @@ export function Workspace() {
lane={currentLane} lane={currentLane}
sessionId={sessionParam} sessionId={sessionParam}
liveRuns={activeRuns?.items ?? []} liveRuns={activeRuns?.items ?? []}
onResumed={(runId) => onResumed={(runId, laneId) =>
setSearchParams({ lane: String(currentLane.id), run: runId }) setSearchParams({ lane: String(laneId ?? currentLane.id), run: runId })
} }
/> />
</div> </div>
@@ -992,8 +1175,9 @@ export function Workspace() {
setSearchParams( setSearchParams(
{ lane: String(currentLane.id), run: runId }, { lane: String(currentLane.id), run: runId },
{ {
// Following the focus is not a new history entry. // Following the focus, or a tab click, is not a new
replace: opts?.focusOnly, // history entry.
replace: opts?.focusOnly || opts?.replace,
state: opts?.split state: opts?.split
? { split: true } ? { split: true }
: opts?.focusOnly : opts?.focusOnly
@@ -1008,14 +1192,29 @@ export function Workspace() {
</div> </div>
</section> </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 && {currentLane &&
(changesOpen ? ( (changesOpen ? (
<aside <aside
id="lane-changes-panel" id="lane-changes-panel"
data-testid="lane-changes-card" data-testid="lane-changes-card"
className={`flex shrink-0 flex-col bg-surface-1 ${CHANGES_WIDTH[changesSize]}${ aria-label={tLanes("changes.title")}
changesSize === "full" ? "" : " border-l border-border" 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 {/* Activity bar: Files · Changes · Sessions, then the resize
control — the same height as the console's tab bar, so the control — the same height as the console's tab bar, so the
@@ -1030,7 +1229,12 @@ export function Workspace() {
type="button" type="button"
data-testid={`panel-tab-${id}`} data-testid={`panel-tab-${id}`}
aria-pressed={panelTab === 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}`)} title={tLanes(`panel.${id}`)}
aria-label={tLanes(`panel.${id}`)} aria-label={tLanes(`panel.${id}`)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${ 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" data-testid="lane-changes-resize"
onClick={() => { onClick={() => {
// Past the widest step the next state is "gone", which // Past the widest step the next state is "gone", which
// is what the rail button reopens at `compact`. // is what the rail button reopens at `compact` — and
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) { // what drops an unsaved edit, so that asks first.
setChangesOpen(false); if (changesFull) {
setChangesSize("compact"); afterDrop(() => {
setChangesOpen(false);
setChangesSize("compact");
});
} else { } else {
setChangesSize(nextChangesSize); setChangesSize(nextChangesSize);
} }
@@ -1082,31 +1289,35 @@ export function Workspace() {
{/* Icon-only, and it draws the width the click LANDS on, {/* Icon-only, and it draws the width the click LANDS on,
not the current one — the label the tooltip carries. */} 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" />; return <Next className="h-4 w-4" />;
})()} })()}
</button> </button>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2"> <div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
<LaneChangesPanel {panelAt && (
// A new tree is a new panel: no read for the previous one <LaneChangesPanel
// can land in it. // A new tree is a new panel: no read for the previous one
key={`${currentLane.id}:${gitSession ?? ""}`} // can land in it. `panelAt` holds still while an edit is
laneId={currentLane.id} // unsaved (see above).
session={gitSession} key={panelKey}
tab={panelTab} laneId={panelAt.laneId}
full={changesSize === "full"} session={panelAt.session}
// A sibling worktree forks from the lane's SOURCE repo when tab={panelTab}
// it has one (this lane is itself a worktree of it); an full={changesFull}
// adopted lane is its own repo. onDirtyChange={setChangesDirty}
onNewLane={(branch) => { // A sibling worktree forks from the lane's SOURCE repo when
setAddLanePrefill({ // it has one (this lane is itself a worktree of it); an
sourceRepo: currentLane.source_repo || currentLane.cwd, // adopted lane is its own repo.
branch, onNewLane={(branch) => {
}); setAddLanePrefill({
setAddLaneOpen(true); sourceRepo: currentLane.source_repo || currentLane.cwd,
}} branch,
/> });
setAddLaneOpen(true);
}}
/>
)}
</div> </div>
</aside> </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> </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. // tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({ vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />, WorkspaceHome: () => <div data-testid="workspace-home" />,
}));
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) => paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`, slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => { 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; 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. // An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({ vi.mock("../../components/lanes/SessionReplay", () => ({
@@ -243,6 +264,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
}, },
sessions: { sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }), 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: [] }), transcript: vi.fn().mockResolvedValue({ messages: [] }),
}, },
}, },
@@ -594,7 +618,7 @@ describe("console tabs", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByTestId("console-tab-new")); await user.click(screen.getByTestId("console-tab-new"));
await user.click(within(screen.getByTestId("console-tab-menu")).getByText("New terminal")); 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 () => { it("disables + once all four slots are taken", async () => {
@@ -782,12 +806,15 @@ describe("split panes", () => {
await right(screen.getByTestId("pane-header-1")); await right(screen.getByTestId("pane-header-1"));
await user.click(screen.getByTestId("pane-menu-split-bottom")); 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 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(); await settle();
expect(shownPanes()).toEqual(["pane-0"]); expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
expect(api.run.kill).not.toHaveBeenCalled(); expect(api.run.kill).not.toHaveBeenCalled();
await right(screen.getByTestId("console-tab-2")); await right(screen.getByTestId("console-tab-2"));
@@ -804,7 +831,7 @@ describe("split panes", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-bottom-1")); await user.click(screen.getByTestId("pane-split-bottom-1"));
await user.click(screen.getByTestId("pane-split-new-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 () => { 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(); const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1")); await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude")); 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 () => { it("with one pane, the split menus sit beside Tools and split it", async () => {
+63
View File
@@ -1926,3 +1926,66 @@ describe("a session's own worktree (claude --worktree)", () => {
assert.equal(r.body.error.code, "ENOWORKTREE"); assert.equal(r.body.error.code, "ENOWORKTREE");
}); });
}); });
// The server, not a stale client list, picks the tab and refuses a second pane
// on a session one already holds.
describe("start: slot auto and a session already live", () => {
const tmux = require("../lib/tmux");
let id;
let cwd;
const live = new Set();
before(async () => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-auto-"));
id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
tmux.__setExecImpl((args) => {
const name = String(args[args.indexOf("-t") + 1] || "").replace(/^=|:$/g, "");
if (args[0] === "has-session" && !live.has(name)) throw new Error("no such session");
if (args[0] === "new-session") live.add(args[args.indexOf("-s") + 1]);
if (args[0] === "list-sessions") return [...live].join("\n");
return "";
});
});
after(() => {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
});
it("takes the first free slot for slot: auto", async () => {
live.add(`agenthub-lane-${id}`);
live.add(`agenthub-lane-${id}-1`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
assert.equal(r.status, 200);
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
});
it("answers ERUNLIVE when every slot is busy", async () => {
live.add(`agenthub-lane-${id}-3`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ERUNLIVE");
live.delete(`agenthub-lane-${id}-3`);
live.delete(`agenthub-lane-${id}-2`);
});
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
const first = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
});
assert.equal(first.status, 200);
const again = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
});
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "ESESSIONLIVE");
assert.equal(again.body.error.run_id, first.body.run_id);
// A fork makes a new session, so it is not a duplicate.
const fork = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
forkSession: true,
});
assert.equal(fork.status, 200);
});
});
+15
View File
@@ -596,6 +596,21 @@ describe("pty-run", () => {
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]); assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
}); });
// A catalog tool has no "resume THIS session" argv; starting it fresh under
// a resume request would read as a resume that silently lost the history.
it("refuses resumeSessionId for a catalog tool instead of starting fresh", () => {
assert.throws(
() =>
pty.spawnRun({
laneId: 15,
cwd: "/tmp/repo",
tool: "antigravity",
resumeSessionId: "abc12345",
}),
{ code: "EUNSUPPORTED" }
);
});
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => { it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
for (const tool of ["claude", "codex", "antigravity"]) { for (const tool of ["claude", "codex", "antigravity"]) {
assert.throws( assert.throws(
+5
View File
@@ -319,6 +319,11 @@ function spawnRun(args) {
) { ) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id"); throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
} }
// Only the hand-written launchers can resume a NAMED session; a catalog
// tool would silently start fresh under a "resume".
if (resumeSessionId && !entry.launcher) {
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
}
if (worktree === true && entry.launcher !== "claude") { if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`); throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
} }
+48 -9
View File
@@ -1779,20 +1779,37 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try { try {
switch (action) { switch (action) {
case "start": { case "start": {
const slot = body.slot === undefined ? 0 : body.slot; // `"auto"`: the first free tab, picked under the lane lock below — a
// client's own pick comes from a list that may already be stale.
const auto = body.slot === "auto";
let slot = body.slot === undefined || auto ? 0 : body.slot;
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) { if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
return res.status(400).json({ return res.status(400).json({
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` }, error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
}); });
} }
const isLive = (laneId, s) =>
runs.getRun(runs.runIdForLane(laneId, s))?.status === "running";
// Under the lock: the free slot, or null when every tab is busy.
const pickSlot = (laneId) => {
if (!auto) return slot;
for (let s = 0; s <= runs.MAX_PANE_SLOT; s++) if (!isLive(laneId, s)) return s;
return null;
};
const allBusy = () =>
res.status(409).json({
error: { code: "ERUNLIVE", message: "every tab of this workspace is busy" },
});
// A Terminal tab: a plain shell in the slot. No agent is at work in // A Terminal tab: a plain shell in the slot. No agent is at work in
// it, so the lane's run_id/status stay as they are. // it, so the lane's run_id/status stay as they are.
if (body.shell === true) { if (body.shell === true) {
const shellOutcome = await withLaneLock(lane.id, async () => { const shellOutcome = await withLaneLock(lane.id, async () => {
const current = lanesLib.getLane(lane.id); const current = lanesLib.getLane(lane.id);
if (!current) return { missing: true }; if (!current) return { missing: true };
const id = runs.runIdForLane(current.id, slot); const picked = pickSlot(current.id);
if (runs.getRun(id)?.status === "running") return { conflict: true }; if (picked === null) return { allBusy: true };
slot = picked;
if (isLive(current.id, slot)) return { conflict: true };
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id; startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
return {}; return {};
}); });
@@ -1801,12 +1818,11 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
.status(404) .status(404)
.json({ error: { code: "ENOLANE", message: "workspace not found" } }); .json({ error: { code: "ENOLANE", message: "workspace not found" } });
} }
if (shellOutcome.allBusy) return allBusy();
if (shellOutcome.conflict) { if (shellOutcome.conflict) {
return res return res.status(409).json({
.status(409) error: { code: "ERUNLIVE", message: "that tab already has a live session" },
.json({ });
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
});
} }
break; break;
} }
@@ -1866,6 +1882,19 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// waited for the lock — the lock makes that race visible instead of // waited for the lock — the lock makes that race visible instead of
// spawning a run for a lane that no longer exists. // spawning a run for a lane that no longer exists.
if (!current) return { missing: true }; if (!current) return { missing: true };
// A session holds ONE pane: a second `--resume` of it would run two
// agents writing one transcript. Its pane is named instead. A fork
// makes a new session, so it is no duplicate.
const resumeTarget = body.resumeSessionId || (body.resume ? current.session_id : null);
if (resumeTarget && body.forkSession !== true) {
const holder = runs
.listRuns()
.find((r) => r.status === "running" && r.sessionId === resumeTarget);
if (holder) return { sessionLive: holder.id };
}
const picked = pickSlot(current.id);
if (picked === null) return { allBusy: true };
slot = picked;
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id; const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id;
const live = liveId ? runs.getRun(liveId) : null; const live = liveId ? runs.getRun(liveId) : null;
if (live && live.status === "running") { if (live && live.status === "running") {
@@ -1907,10 +1936,20 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
.status(404) .status(404)
.json({ error: { code: "ENOLANE", message: "workspace not found" } }); .json({ error: { code: "ENOLANE", message: "workspace not found" } });
} }
if (outcome.sessionLive) {
return res.status(409).json({
error: {
code: "ESESSIONLIVE",
message: "that session is already open in a tab",
run_id: outcome.sessionLive,
},
});
}
if (outcome.allBusy) return allBusy();
if (outcome.conflict) { if (outcome.conflict) {
return res return res
.status(409) .status(409)
.json({ error: { code: "ERUNLIVE", message: "workspace already has a live run" } }); .json({ error: { code: "ERUNLIVE", message: "that tab already has a live session" } });
} }
break; break;
} }