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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user