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
@@ -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<Record<string, unknown>>();
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");
});
});