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:
@@ -234,6 +234,60 @@ describe("TerminalView", () => {
|
||||
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", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// 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 }));
|
||||
});
|
||||
|
||||
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
|
||||
// ResizeObserver watches, which is why onResize is the hook.
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
@@ -284,7 +338,12 @@ describe("TerminalView", () => {
|
||||
ws.sent.length = 0;
|
||||
termState.cols = 129;
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user