From efa417488a055b5abf0d99aced89025fddee9d1d Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 5 Oct 2026 13:22:12 +0700 Subject: [PATCH] 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. --- client/package-lock.json | 39 ++ client/package.json | 3 + client/src/components/WorkspaceTree.tsx | 17 +- .../__tests__/WorkspaceTree.test.tsx | 11 +- client/src/components/lanes/AddLaneModal.tsx | 6 +- .../components/lanes/FolderBrowseModal.tsx | 18 +- client/src/components/lanes/LaneCard.tsx | 66 ++-- .../src/components/lanes/LaneChangesPanel.tsx | 132 +++++-- client/src/components/lanes/LaneDiffView.tsx | 47 ++- client/src/components/lanes/LaneGitBar.tsx | 32 +- client/src/components/lanes/LaneGitHeader.tsx | 8 +- client/src/components/lanes/PipelineMap.tsx | 2 +- client/src/components/lanes/SessionMenu.tsx | 29 +- client/src/components/lanes/SessionReplay.tsx | 180 ++++++--- .../components/lanes/SessionResumeButton.tsx | 22 +- client/src/components/lanes/WorkspaceHome.tsx | 215 ++++++++-- client/src/components/lanes/WorkspaceRow.tsx | 46 ++- .../src/components/lanes/WorkspaceRowMenu.tsx | 85 ++-- .../components/lanes/WorkspaceSessionList.tsx | 120 +++++- .../lanes/__tests__/LaneChangesPanel.test.tsx | 50 ++- .../lanes/__tests__/LaneGitBar.test.tsx | 5 +- .../lanes/__tests__/SessionReplay.test.tsx | 99 ++++- .../__tests__/SessionResumeButton.test.tsx | 44 ++- .../lanes/__tests__/WorkspaceHome.test.tsx | 44 ++- client/src/components/lanes/startSession.ts | 63 +++ client/src/components/run/ConsoleTabs.tsx | 212 ++++++++-- client/src/components/run/LaneConsolePane.tsx | 13 +- client/src/components/run/PaneGrid.tsx | 66 +++- client/src/components/run/RunSetup.tsx | 18 +- client/src/components/run/TerminalView.tsx | 259 +++++++++++- .../run/__tests__/TerminalView.test.tsx | 63 ++- client/src/i18n/locales/en/lanes.json | 46 ++- client/src/i18n/locales/en/run.json | 12 + client/src/i18n/locales/vi/lanes.json | 46 ++- client/src/i18n/locales/vi/run.json | 20 +- client/src/lib/__tests__/panePaste.test.ts | 24 +- client/src/lib/panePaste.ts | 48 ++- client/src/pages/Workspace.tsx | 370 ++++++++++++++---- client/src/pages/__tests__/Workspace.test.tsx | 39 +- server/__tests__/lanes-api.test.js | 63 +++ server/__tests__/pty-run.test.js | 15 + server/lib/pty-run.js | 5 + server/routes/lanes.js | 57 ++- 43 files changed, 2252 insertions(+), 507 deletions(-) create mode 100644 client/src/components/lanes/startSession.ts diff --git a/client/package-lock.json b/client/package-lock.json index 1acc411e..f915f3ea 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,7 +11,10 @@ "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "d3": "^7.9.0", "i18next": "^26.0.8", @@ -2136,6 +2139,18 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/addon-clipboard": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz", + "integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + }, + "peerDependencies": { + "@xterm/xterm": "^5.4.0" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", @@ -2145,6 +2160,24 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/addon-search": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", + "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, "node_modules/@xterm/xterm": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", @@ -3711,6 +3744,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-base64": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz", + "integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/client/package.json b/client/package.json index e353c61d..1390acd0 100644 --- a/client/package.json +++ b/client/package.json @@ -14,7 +14,10 @@ "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "d3": "^7.9.0", "i18next": "^26.0.8", diff --git a/client/src/components/WorkspaceTree.tsx b/client/src/components/WorkspaceTree.tsx index ed343dcd..084bcf02 100644 --- a/client/src/components/WorkspaceTree.tsx +++ b/client/src/components/WorkspaceTree.tsx @@ -168,11 +168,17 @@ export function WorkspaceTree() { } }; - const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => { + const onMenuAction = (lane: Lane, action: WorkspaceMenuAction, runId?: string) => { if (action === "rename") setRenamingId(lane.id); else if (action === "new-here") openAdd(lane.source_repo || lane.cwd); else if (action === "reset" || action === "remove") setDestructive({ lane, action }); - else if (action === "started") select(lane.id); + // Start opens the session it started, as the home's menu does. + else if (action === "started") + navigate( + runId + ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}` + : `/workspace?lane=${lane.id}` + ); }; const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => { @@ -192,7 +198,8 @@ export function WorkspaceTree() { } }; - const headBtn = "rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"; + // ≥24px targets (WCAG 2.5.8); each carries an aria-label. + const headBtn = "btn-icon"; return (
@@ -205,7 +212,7 @@ export function WorkspaceTree() { onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })} aria-label={t("tree.toolFilter")} title={t("tree.toolFilter")} - className="max-w-[6.5rem] rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] text-fg-secondary" + className="select max-w-[6.5rem] !rounded !py-0.5 !pl-1 !pr-5 !text-[11px] text-fg-secondary" > {tools.map((id) => ( @@ -307,7 +314,7 @@ export function WorkspaceTree() { x={menu.x} y={menu.y} onClose={() => setMenu(null)} - onAction={(a) => onMenuAction(menu.lane, a)} + onAction={(a, runId) => onMenuAction(menu.lane, a, runId)} /> )} {destructive && ( diff --git a/client/src/components/__tests__/WorkspaceTree.test.tsx b/client/src/components/__tests__/WorkspaceTree.test.tsx index 7fa04d06..dbd096d7 100644 --- a/client/src/components/__tests__/WorkspaceTree.test.tsx +++ b/client/src/components/__tests__/WorkspaceTree.test.tsx @@ -44,6 +44,11 @@ const session = (id: string, name: string) => let lanes: Lane[] = []; +// Archiving a live session asks through the themed confirm (App's host): yes. +vi.mock("../../lib/confirm", () => ({ + confirmDialog: vi.fn(async () => true), + confirmDiscard: vi.fn(async () => true), +})); vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { @@ -65,6 +70,10 @@ vi.mock("../../lib/api", async (importOriginal) => { sessions: [session("s-live", "Live one"), session("s-old", "Old one")], })), patch: vi.fn(async () => ({ session: {} })), + // A tab's session with no row yet (a bare tab): 404. + get: vi.fn(async () => { + throw Object.assign(new Error("not found"), { status: 404 }); + }), }, run: { list: vi.fn(async () => ({ @@ -483,6 +492,6 @@ describe("workspaceDot", () => { expect(workspaceDot(lane(1, "/a", { status: "error" }))).toBe("bg-status-danger"); expect(workspaceDot(lane(1, "/a", { status: "running" }))).toContain("animate-pulse-slow"); expect(workspaceDot(lane(1, "/a", { liveness: "active" }))).toBe("bg-status-success"); - expect(workspaceDot(lane(1, "/a"))).toBe("bg-surface-4"); + expect(workspaceDot(lane(1, "/a"))).toBe("border border-fg-muted"); }); }); diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 3f4312ca..34fbcc84 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -65,7 +65,7 @@ function Seg({ aria-pressed={active} className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${ active - ? "bg-accent text-white shadow-sm" + ? "bg-accent-solid text-white shadow-sm" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" }`} > @@ -567,7 +567,7 @@ export function AddLaneModal({ id="add-lane-base" value={base} onChange={(e) => setBase(e.target.value)} - className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none" + className="select w-full !py-1.5 !text-xs" > {branches.map((b) => (
@@ -327,6 +312,7 @@ export default function LaneCard({ setMenuAt(null)}> { setMenuAt(null); setDestructiveAction("reset"); diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index 1b58a9ad..e3d3505f 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -32,7 +32,15 @@ * * A diff line's number opens a comment box (LaneDiffView `onComment`); the * comment is pasted, as `: — `, 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ĩ */ @@ -61,6 +69,12 @@ import { } from "lucide-react"; import { api, gitTarget } from "../../lib/api"; import { pasteToLane } from "../../lib/panePaste"; +import { confirmDiscard as askDiscard } from "../../lib/confirm"; +import { copyText } from "../../lib/clipboard"; +import { isSubmitEnter } from "../../lib/keys"; +import { toast } from "../../lib/toast"; +import { useArmed } from "../../hooks/useArmed"; +import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView"; import { LaneConflictView } from "./LaneConflictView"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; @@ -428,14 +442,21 @@ function ChangeRow({ ); } +/** History's page size, and the most the server reads (`listCommits`). */ +const HISTORY_PAGE = 50; +const HISTORY_MAX = 200; + export function LaneChangesPanel({ laneId, session, onNewLane, full = false, tab = "changes", + onDirtyChange, }: { laneId: number; + /** The whole-file editor holds unsaved text (true) or not (false). */ + onDirtyChange?: (dirty: boolean) => void; /** Show and act on that session's own worktree (`claude --worktree`) * instead of the workspace's folder. The Workspace keys the panel on it, * so a different session is a fresh panel. */ @@ -457,7 +478,17 @@ export function LaneChangesPanel({ // Every git read and write below goes to the same tree as those facts. const target = gitTarget(laneId, session); // A diff line's comment goes into this workspace's pane, as a paste. - const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]); + const comment = useCallback( + (text: string) => { + const runId = pasteToLane(laneId, text); + if (runId) { + const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0); + toast.success(t("changes.commentPasted", { tab: slot + 1 })); + } + return runId !== null; + }, + [laneId, t] + ); // Bumped by Refresh and by a git bar action: a pull or a switch moves // history and the tree too, not just the diff `load` re-reads. const [reread, setReread] = useState(0); @@ -523,12 +554,21 @@ export function LaneChangesPanel({ }, [text]); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); - const [confirmDiscard, setConfirmDiscard] = useState(null); - const [armedAbort, setArmedAbort] = useState(false); + // Two-click arming that disarms by itself after 3 s: the armed path, the + // armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort. + const discardArm = useArmed(); + const confirmDiscard = discardArm.armed; + const setConfirmDiscard = (path: string | null) => + path ? discardArm.arm(path) : discardArm.disarm(); + const abortArm = useArmed(); + const armedAbort = abortArm.armed !== null; + const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm()); // A conflicted file opens in the conflict view; true = the plain editor. const [rawEdit, setRawEdit] = useState(false); - // The hunk whose Undo was clicked once: `${path}\n${header}`. - const [armedHunk, setArmedHunk] = useState(null); + // The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`. + const hunkArm = useArmed(); + const armedHunk = hunkArm.armed; + const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm()); // Files folded shut in review mode. A change-set is read file by file, and // a file already read is in the way of the next one — so the set is what is // COLLAPSED, and a file arriving later is open by default. @@ -603,13 +643,19 @@ export function LaneChangesPanel({ // History and the file tree are their own reads, each fetched when its mode // is entered rather than alongside the diff nobody is looking at. + // History reads HISTORY_PAGE commits, and Load more asks for the next page + // (up to the server's 200). + const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE); + const [historyLoading, setHistoryLoading] = useState(false); useEffect(() => { if (mode !== "history") return; + setHistoryLoading(true); void api.lanes - .gitLog(target, { limit: 50 }) + .gitLog(target, { limit: historyLimit }) .then(forLane(laneId, (r) => setCommits(r.commits))) - .catch((err) => setError(err instanceof Error ? err.message : String(err))); - }, [mode, laneId, reread]); + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => laneNow.current === laneId && setHistoryLoading(false)); + }, [mode, laneId, reread, historyLimit]); // Re-run when a lane switch empties the cache too: the Files tab stays in // `browse` across the switch, so the mode alone never changes. @@ -739,8 +785,14 @@ export function LaneChangesPanel({ pendingScroll.current = null; }, [laneId]); // After the lane reset above: each tab opens on its own mode. + // Leaving the open file this way (the Workspace asked first when it was + // edited) drops it whole: a draft left behind would keep `dirty` true. useEffect(() => { - if (!full) setMode(tab === "files" ? "browse" : "overview"); + if (full) return; + setMode(tab === "files" ? "browse" : "overview"); + setOpenFile(null); + setWhole(false); + setDraft(null); }, [tab, laneId, full]); const filesTab = tab === "files" && !full; @@ -756,7 +808,11 @@ export function LaneChangesPanel({ pendingScroll.current = null; }, [mode, data]); - const show = (path: string) => { + // Every way out of an open, edited file asks once; nothing else does. + const dirtyRef = useRef(false); + const confirmDrop = async () => !dirtyRef.current || (await askDiscard()); + + const showNow = (path: string) => { // Opening a file FROM a file (the review list is gone by then) must not // overwrite the list position that is still waiting to be restored. if (mode !== "file") { @@ -773,13 +829,21 @@ export function LaneChangesPanel({ setMode("file"); }; - const goBack = () => { + const show = (path: string) => { + void confirmDrop().then((ok) => ok && showNow(path)); + }; + + const goBackNow = () => { const back = returnTo.current; pendingScroll.current = back?.scroll ?? 0; returnTo.current = null; setMode(back?.mode ?? (range === "commit" ? "review" : "overview")); setOpenFile(null); setWhole(false); + setDraft(null); + }; + const goBack = () => { + void confirmDrop().then((ok) => ok && goBackNow()); }; // Save the editor over exactly the bytes it opened (`sha`): a file the agent @@ -860,7 +924,7 @@ export function LaneChangesPanel({ setRange("commit"); setMode("review"); }; - const copy = (text: string) => void navigator.clipboard.writeText(text); + const copy = (text: string) => void copyText(text); // A menu item: close its menu, then act. const pick = (close: () => void, fn: () => void) => () => { close(); @@ -952,7 +1016,7 @@ export function LaneChangesPanel({ testId="lane-changes-tree-menu" > {!treeCtx.menu.item.dir && ( - openWhole(treeCtx.menu!.item.path))}> + void openWhole(treeCtx.menu!.item.path))}> {t("changes.openFile", { path: treeCtx.menu.item.name })} )} @@ -1090,6 +1154,14 @@ export function LaneChangesPanel({ }); const dirty = draft !== null && draft !== text?.lines.join("\n"); + dirtyRef.current = dirty; + useUnsavedGuard(dirty); + const dirtyChangeRef = useRef(onDirtyChange); + dirtyChangeRef.current = onDirtyChange; + useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]); + // Unmounted with an edit open (the panel closed or moved on, after the + // Workspace asked): nothing is dirty any more. + useEffect(() => () => dirtyChangeRef.current?.(false), []); const files = data?.available ? data.files : []; const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path)); @@ -1110,9 +1182,9 @@ export function LaneChangesPanel({ // A tree or quick-open pick opens the whole file — and, with an edit open, // would drop it, so a dirty draft asks first. - const openWhole = (p: string) => { - if (dirty && !window.confirm(t("changes.dropEdits"))) return; - show(p); + const openWhole = async (p: string) => { + if (!(await confirmDrop())) return; + showNow(p); setWhole(true); }; @@ -1156,7 +1228,7 @@ export function LaneChangesPanel({
  • ))} + {commits === null && ( +
  • {t("changes.loading")}
  • + )} {commits && commits.length === 0 && (
  • {t("changes.noCommits")}
  • )} + {commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && ( +
  • + +
  • + )} ) : mode === "browse" && !full ? ( browsePane @@ -1469,7 +1557,7 @@ export function LaneChangesPanel({ value={message} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && message.trim() && !busy) void commitStaged(); + if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged(); }} placeholder={t("changes.commitPlaceholder")} aria-label={t("changes.commitPlaceholder")} @@ -1670,7 +1758,7 @@ export function LaneChangesPanel({ + )}
    -