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
+110 -22
View File
@@ -32,7 +32,15 @@
*
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter.
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
* a toast says which tab took it, and a console not on screen takes nothing.
*
* An unsaved whole-file edit is never dropped silently: Back, another file,
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
* reloading the browser tab asks the browser's own question, and
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
* switching its tab, or moving it to another workspace or session. Two-click
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -61,6 +69,12 @@ import {
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -428,14 +442,21 @@ function ChangeRow({
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
export function LaneChangesPanel({
laneId,
session,
onNewLane,
full = false,
tab = "changes",
onDirtyChange,
}: {
laneId: number;
/** The whole-file editor holds unsaved text (true) or not (false). */
onDirtyChange?: (dirty: boolean) => void;
/** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
@@ -457,7 +478,17 @@ export function LaneChangesPanel({
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
const comment = useCallback(
(text: string) => {
const runId = pasteToLane(laneId, text);
if (runId) {
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
}
return runId !== null;
},
[laneId, t]
);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
@@ -523,12 +554,21 @@ export function LaneChangesPanel({
}, [text]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [armedAbort, setArmedAbort] = useState(false);
// Two-click arming that disarms by itself after 3 s: the armed path, the
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
const discardArm = useArmed();
const confirmDiscard = discardArm.armed;
const setConfirmDiscard = (path: string | null) =>
path ? discardArm.arm(path) : discardArm.disarm();
const abortArm = useArmed();
const armedAbort = abortArm.armed !== null;
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
// A conflicted file opens in the conflict view; true = the plain editor.
const [rawEdit, setRawEdit] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null);
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
const hunkArm = useArmed();
const armedHunk = hunkArm.armed;
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
// Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
@@ -603,13 +643,19 @@ export function LaneChangesPanel({
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
// History reads HISTORY_PAGE commits, and Load more asks for the next page
// (up to the server's 200).
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (mode !== "history") return;
setHistoryLoading(true);
void api.lanes
.gitLog(target, { limit: 50 })
.gitLog(target, { limit: historyLimit })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId, reread]);
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
}, [mode, laneId, reread, historyLimit]);
// Re-run when a lane switch empties the cache too: the Files tab stays in
// `browse` across the switch, so the mode alone never changes.
@@ -739,8 +785,14 @@ export function LaneChangesPanel({
pendingScroll.current = null;
}, [laneId]);
// After the lane reset above: each tab opens on its own mode.
// Leaving the open file this way (the Workspace asked first when it was
// edited) drops it whole: a draft left behind would keep `dirty` true.
useEffect(() => {
if (!full) setMode(tab === "files" ? "browse" : "overview");
if (full) return;
setMode(tab === "files" ? "browse" : "overview");
setOpenFile(null);
setWhole(false);
setDraft(null);
}, [tab, laneId, full]);
const filesTab = tab === "files" && !full;
@@ -756,7 +808,11 @@ export function LaneChangesPanel({
pendingScroll.current = null;
}, [mode, data]);
const show = (path: string) => {
// Every way out of an open, edited file asks once; nothing else does.
const dirtyRef = useRef(false);
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
const showNow = (path: string) => {
// Opening a file FROM a file (the review list is gone by then) must not
// overwrite the list position that is still waiting to be restored.
if (mode !== "file") {
@@ -773,13 +829,21 @@ export function LaneChangesPanel({
setMode("file");
};
const goBack = () => {
const show = (path: string) => {
void confirmDrop().then((ok) => ok && showNow(path));
};
const goBackNow = () => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
setDraft(null);
};
const goBack = () => {
void confirmDrop().then((ok) => ok && goBackNow());
};
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
@@ -860,7 +924,7 @@ export function LaneChangesPanel({
setRange("commit");
setMode("review");
};
const copy = (text: string) => void navigator.clipboard.writeText(text);
const copy = (text: string) => void copyText(text);
// A menu item: close its menu, then act.
const pick = (close: () => void, fn: () => void) => () => {
close();
@@ -952,7 +1016,7 @@ export function LaneChangesPanel({
testId="lane-changes-tree-menu"
>
{!treeCtx.menu.item.dir && (
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
<MenuItem onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}>
{t("changes.openFile", { path: treeCtx.menu.item.name })}
</MenuItem>
)}
@@ -1090,6 +1154,14 @@ export function LaneChangesPanel({
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
dirtyRef.current = dirty;
useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange);
dirtyChangeRef.current = onDirtyChange;
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
// Unmounted with an edit open (the panel closed or moved on, after the
// Workspace asked): nothing is dirty any more.
useEffect(() => () => dirtyChangeRef.current?.(false), []);
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
@@ -1110,9 +1182,9 @@ export function LaneChangesPanel({
// A tree or quick-open pick opens the whole file — and, with an edit open,
// would drop it, so a dirty draft asks first.
const openWhole = (p: string) => {
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
show(p);
const openWhole = async (p: string) => {
if (!(await confirmDrop())) return;
showNow(p);
setWhole(true);
};
@@ -1156,7 +1228,7 @@ export function LaneChangesPanel({
<li key={p}>
<button
type="button"
onClick={() => openWhole(p)}
onClick={() => void openWhole(p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
@@ -1175,7 +1247,7 @@ export function LaneChangesPanel({
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={openWhole}
onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null}
/>
@@ -1235,7 +1307,7 @@ export function LaneChangesPanel({
onClick={() => setMode(m)}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m
? "bg-accent text-white"
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
@@ -1437,9 +1509,25 @@ export function LaneChangesPanel({
</button>
</li>
))}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
<li>
<button
type="button"
data-testid="lane-changes-history-more"
disabled={historyLoading}
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
</button>
</li>
)}
</ul>
) : mode === "browse" && !full ? (
browsePane
@@ -1469,7 +1557,7 @@ export function LaneChangesPanel({
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
}}
placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")}
@@ -1670,7 +1758,7 @@ export function LaneChangesPanel({
<button
type="button"
disabled={busy}
onClick={() => setDraft(null)}
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("changes.cancelEdit")}