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
@@ -24,13 +24,24 @@ vi.mock("../../run/RunSetup", () => ({
PromptEditor: ({
value,
onChange,
onSubmit,
placeholder,
}: {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
}) => (
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
<textarea
aria-label={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onSubmit();
}}
/>
),
ModelMenu: ({
value,
@@ -199,7 +210,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "fix the login",
permissionMode: "auto",
});
@@ -248,7 +259,7 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "port it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "port it",
tool: "codex",
sandbox: "workspace-write",
@@ -300,23 +311,40 @@ describe("WorkspaceHome", () => {
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
slot: "auto",
prompt: "try it",
permissionMode: "auto",
worktree: true,
});
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
it("says so when the server finds all four panes busy", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start"));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
});
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
let release: (v: { run_id: string }) => void = () => undefined;
vi.mocked(api.lanes.action).mockImplementationOnce(
() => new Promise((r) => (release = r)) as never
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
const box = screen.getByLabelText("How can I help you today?");
expect(screen.getByTestId("workspace-home-start")).toBeDisabled();
await user.type(box, "{Enter}");
expect(api.lanes.action).not.toHaveBeenCalled();
await user.type(box, "go{Enter}{Enter}");
release({ run_id: "agenthub-lane-1-1" });
await waitFor(() => expect(api.lanes.action).toHaveBeenCalledTimes(1));
});
it("folds the archived sessions with Unarchive", async () => {