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
+241 -18
View File
@@ -16,13 +16,29 @@
* and how `claude` attaches an image. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
* mode on Shift+Tab), so Ctrl+M toggles "Tab moves focus" — VS Code's escape
* from its terminal — announced by a hint the textarea is described by;
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
* (OSC 52 — tmux's default `set-clipboard external` passes its own
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
* READ the user's clipboard). While the socket is down a "Reconnecting…"
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
* most every 150 ms.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { ArrowDown, ArrowUp, X } from "lucide-react";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
@@ -30,6 +46,37 @@ import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
const readFlag = (key: string) => {
try {
return window.localStorage.getItem(key) === "1";
} catch {
return false;
}
};
/** OSC 52 from the pane may WRITE the clipboard (a tmux selection, a CLI's
* copy) but never read it: a read would hand the user's clipboard to
* whatever runs in the pane. */
const clipboardProvider: IClipboardProvider = {
readText: () => "",
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
// clipboard. An empty write (a "clear") is ignored.
writeText: (_selection, text) => {
// Silent and best effort: no toast per copy, and on a non-secure origin
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
},
};
// The addon's typings declare one argument, its code takes (base64, provider).
const ClipboardWithProvider = ClipboardAddon as unknown as new (
base64: undefined,
provider: IClipboardProvider
) => ClipboardAddon;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
interface TerminalViewProps {
@@ -62,7 +109,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
const { t } = useTranslation("run");
const tRef = useRef(t);
tRef.current = t;
const [drop, setDrop] = useState<DropState | null>(null);
// The socket dropped after it had been up: keystrokes are not reaching tmux.
const [reconnecting, setReconnecting] = useState(false);
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
const [tabMoves, setTabMoves] = useState(false);
const tabMovesRef = useRef(false);
const [find, setFind] = useState<string | null>(null);
const searchRef = useRef<SearchAddon | null>(null);
const findInput = useRef<HTMLInputElement>(null);
const [selectHint, setSelectHint] = useState(() => !readFlag(SELECT_HINT_KEY));
const hintId = useId();
// dragenter/dragleave fire for every child crossed, so count them: the hint
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
@@ -121,11 +180,22 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
const search = new SearchAddon();
term.loadAddon(search);
searchRef.current = search;
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
term.loadAddon(
new WebLinksAddon((_e, uri) => {
window.open(uri, "_blank", "noopener,noreferrer");
})
);
if (containerRef.current) term.open(containerRef.current);
term.textarea?.setAttribute("aria-describedby", hintId);
// Draw each cell at a fixed position instead of letting the browser lay the
// row out as text. xterm's default DOM renderer aligns a row with a single
@@ -171,8 +241,33 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
// here while working in a real terminal. Returning true keeps xterm's own
// handling; the preventDefault only stops focus from escaping.
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
// handling — and from cancelling — the key.
term.attachCustomKeyEventHandler((ev) => {
if (ev.type === "keydown" && ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
if (ev.type !== "keydown") return true;
const key = ev.key.toLowerCase();
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
ev.preventDefault();
tabMovesRef.current = !tabMovesRef.current;
setTabMoves(tabMovesRef.current);
return false;
}
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
// which is readline's forward-char (and bound by many TUIs).
const findChord =
!ev.altKey &&
key === "f" &&
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey));
if (findChord) {
ev.preventDefault();
setFind((f) => f ?? "");
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
});
@@ -192,11 +287,22 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
* (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing.
*/
const sendSize = () => {
const sendSizeNow = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
// A divider drag or a window resize fires a resize per frame; tmux and the
// program in the pane redraw on each, so the LAST size is sent once things
// settle (trailing, 150 ms).
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
const sendSize = () => {
if (sizeTimer) clearTimeout(sizeTimer);
sizeTimer = setTimeout(() => {
sizeTimer = null;
sendSizeNow();
}, 150);
};
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let ended = false; // server said the pane exited — nothing left to attach to
let disposed = false;
@@ -219,7 +325,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
sock.binaryType = "arraybuffer";
sock.onopen = () => {
sendSize();
setReconnecting(false);
sendSizeNow();
};
sock.onmessage = (event) => {
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
@@ -237,7 +344,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const msg = JSON.parse(data);
if (msg.type === "exit") {
ended = true;
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
onExitRef.current?.();
}
return;
@@ -251,7 +358,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
sock.onclose = () => {
if (disposed || ended) return;
// tmux keeps the session alive across a server restart, so re-attaching
// repaints the pane in full — no output is lost by retrying.
// repaints the pane in full — no output is lost by retrying. Input is
// not: the overlay says so rather than letting keystrokes vanish.
setReconnecting(true);
retryTimer = setTimeout(connect, 1500);
};
};
@@ -270,7 +379,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
// the window to whichever client was last active — resizing this pane out
// from under a terminal that is never told. Coming back to it says so.
const reassert = () => sendSize();
const reassert = () => sendSizeNow();
term.textarea?.addEventListener("focus", reassert);
const refit = () => {
@@ -296,6 +405,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
return () => {
disposed = true;
if (retryTimer) clearTimeout(retryTimer);
if (sizeTimer) clearTimeout(sizeTimer);
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
@@ -315,16 +426,46 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// Other parts of the page (a diff-line comment) paste into this pane.
useEffect(
() =>
registerPane(runId, (text) => {
termRef.current?.paste(text);
termRef.current?.focus();
}),
registerPane(
runId,
(text) => {
termRef.current?.paste(text);
termRef.current?.focus();
},
// On screen: a console hidden with CSS measures 0×0.
() => !!containerRef.current?.offsetWidth
),
[runId]
);
const runFind = (backwards = false) => {
const q = find ?? "";
if (!q) return;
if (backwards) searchRef.current?.findPrevious(q);
else searchRef.current?.findNext(q);
};
const closeFind = () => {
setFind(null);
searchRef.current?.clearDecorations();
termRef.current?.focus();
};
const dismissSelectHint = () => {
setSelectHint(false);
try {
window.localStorage.setItem(SELECT_HINT_KEY, "1");
} catch {
/* shown again next time */
}
};
const chip =
"absolute z-10 rounded-md border border-white/15 bg-black/70 px-2 py-1 text-[11px] text-white/90";
return (
<div
className="relative h-full w-full"
// The gutter around the terminal is the terminal's own colour, so a
// dark pane never sits in a light-page frame.
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
style={{ backgroundColor: palette.background }}
onDragEnter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
@@ -344,12 +485,94 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
onDrop={(e) => void onDrop(e)}
onFocus={() => notePaneFocus(runId)}
>
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
<p id={hintId} className="sr-only">
{t("terminal.keyboardHint")}
</p>
{tabMoves && (
<span data-testid="terminal-tab-moves" role="status" className={`${chip} bottom-2 right-3`}>
{t("terminal.tabMoves")}
</span>
)}
{selectHint && !reconnecting && (
<span className={`${chip} bottom-2 left-3 flex items-center gap-2`}>
{t("terminal.selectHint")}
<button
type="button"
onClick={dismissSelectHint}
aria-label={t("terminal.dismissHint")}
title={t("terminal.dismissHint")}
className="rounded p-0.5 hover:bg-white/15"
>
<X className="h-3 w-3" />
</button>
</span>
)}
{find !== null && (
<div
data-testid="terminal-find"
role="search"
className={`${chip} right-3 top-2 flex items-center gap-1 !p-1`}
>
<input
ref={findInput}
value={find}
aria-label={t("terminal.find")}
placeholder={t("terminal.find")}
onChange={(e) => {
setFind(e.target.value);
if (e.target.value)
searchRef.current?.findNext(e.target.value, { incremental: true });
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
closeFind();
} else if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
runFind(e.shiftKey);
}
}}
className="w-44 rounded bg-white/10 px-1.5 py-0.5 text-white placeholder:text-white/50 focus:outline-none"
/>
<button
type="button"
onClick={() => runFind(true)}
aria-label={t("terminal.findPrev")}
title={t("terminal.findPrev")}
className="rounded p-0.5 hover:bg-white/15"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => runFind()}
aria-label={t("terminal.findNext")}
title={t("terminal.findNext")}
className="rounded p-0.5 hover:bg-white/15"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={closeFind}
aria-label={t("terminal.findClose")}
title={t("terminal.findClose")}
className="rounded p-0.5 hover:bg-white/15"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{reconnecting && (
<div
data-testid="terminal-reconnecting"
role="status"
className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 text-sm text-white"
>
{t("terminal.reconnecting")}
</div>
)}
{drop && (
// pointer-events-none: the drop must land on the terminal, not on this.
<div