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
@@ -15,9 +15,15 @@ import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
vi.mock("../../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true),
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: {
@@ -134,7 +140,13 @@ beforeEach(() => {
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
function Tabbed({ laneId }: { laneId: number }) {
function Tabbed({
laneId,
onDirtyChange,
}: {
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
@@ -144,7 +156,7 @@ function Tabbed({ laneId }: { laneId: number }) {
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} />
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
}
@@ -404,7 +416,12 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel drops the edit and goes back to reading.
// Cancel asks before dropping an edit: "keep" keeps it…
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(confirmDiscard).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" drops it and goes back to reading.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
@@ -739,6 +756,33 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
@@ -16,6 +16,8 @@ import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
// A merge asks through the themed confirm (its host lives in App): yes.
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
@@ -371,7 +373,6 @@ describe("git login (issue #45)", () => {
describe("merge (issue #45)", () => {
it("merges the picked branch into the current one after a confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
@@ -380,11 +381,9 @@ describe("merge (issue #45)", () => {
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
);
confirm.mockRestore();
});
it("offers to merge the upstream when the branch has diverged", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
@@ -2,14 +2,16 @@
* @file SessionReplay.test.tsx
* @description An ended session opened in its workspace: its conversation and
* a prompt box; opening it starts nothing, and only a SENT message resumes it
* (`resumeSessionId` + the prompt, in the first free pane slot), after which
* the pane opens.
* (`resumeSessionId` + the prompt, in the free tab the server picks), after
* which the pane opens; a session a tab already holds opens that tab, and a
* tool that cannot resume by id offers a new session instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane } from "../../../lib/types";
import type { RunHandle } from "../../../lib/api";
@@ -33,45 +35,112 @@ vi.mock("../../run/RunSetup", () => ({
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
vi.mock("../WorkspaceHome", () => ({
freeSlot: (_id: number, runs: RunHandle[]) => (runs.length ? 1 : 0),
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
vi.mock("../startSession", () => ({
startInFreeTabDetailed: vi.fn(async () => ({
runId: "agenthub-lane-1-1",
laneId: 1,
held: false,
})),
}));
vi.mock("../../../lib/panePaste", () => ({ pasteToRun: vi.fn(() => true) }));
vi.mock("../SessionResumeButton", () => ({ SessionResumeButton: () => null }));
vi.mock("../../../lib/api", () => ({
api: {
sessions: {
get: vi.fn(async () => ({ session: { id: "s-old", tool: "claude", cwd: "/r" } })),
},
lanes: { action: vi.fn(async () => ({ run_id: "agenthub-lane-1-1" })) },
run: { list: vi.fn(async () => ({ items: [] })) },
},
}));
import { api } from "../../../lib/api";
import { startInFreeTabDetailed as startInFreeTab } from "../startSession";
import { SessionReplay } from "../SessionReplay";
import { pasteToRun } from "../../../lib/panePaste";
const lane = { id: 1, cwd: "/r", tool: "claude" } as unknown as Lane;
const busy = [{ id: "agenthub-lane-1", laneId: 1, slot: 0, status: "running" }] as RunHandle[];
const holding = (sessionId: string) =>
[{ id: "agenthub-lane-1-2", laneId: 1, slot: 2, status: "running", sessionId }] as RunHandle[];
beforeEach(() => vi.clearAllMocks());
const renderIn = (ui: React.ReactElement) => render(<MemoryRouter>{ui}</MemoryRouter>);
describe("SessionReplay", () => {
it("shows the conversation and resumes nothing until a message is sent", async () => {
const onResumed = vi.fn();
const user = userEvent.setup();
render(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
expect(screen.getByTestId("conversation")).toHaveTextContent("s-old");
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old"));
// The row only — never the session's whole event history.
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old", { events: false }));
expect(screen.getByTestId("session-replay-send")).toBeDisabled();
expect(api.lanes.action).not.toHaveBeenCalled();
expect(startInFreeTab).not.toHaveBeenCalled();
await user.type(screen.getByLabelText("Message to continue this session…"), "carry on");
await user.click(screen.getByTestId("session-replay-send"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
resumeSessionId: "s-old",
prompt: "carry on",
// The server picks the free tab (`slot: "auto"`, inside startInFreeTab).
await waitFor(() =>
expect(startInFreeTab).toHaveBeenCalledWith(1, {
resumeSessionId: "s-old",
prompt: "carry on",
})
);
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1", 1));
});
it("opens the tab that already holds the session instead of resuming it again", async () => {
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(
<SessionReplay
lane={lane}
sessionId="s-old"
liveRuns={holding("s-old")}
onResumed={onResumed}
/>
);
expect(screen.queryByTestId("session-replay-send")).toBeNull();
await user.click(await screen.findByRole("button", { name: "Open pane" }));
expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(startInFreeTab).not.toHaveBeenCalled();
});
it("re-reads the panes on send: a tab that took the session meanwhile is opened", async () => {
vi.mocked(api.run.list).mockResolvedValueOnce({ items: holding("s-old") } as never);
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
await user.type(screen.getByLabelText("Message to continue this session…"), "go");
await user.click(screen.getByTestId("session-replay-send"));
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2", 1));
expect(startInFreeTab).not.toHaveBeenCalled();
// The typed message goes into that tab once it is on screen.
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-1-2", "go"));
});
it("opens the pane the server says holds it (even another workspace's) and pastes there", async () => {
vi.mocked(startInFreeTab).mockResolvedValueOnce({
runId: "agenthub-lane-9-1",
laneId: 9,
held: true,
});
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1"));
const onResumed = vi.fn();
const user = userEvent.setup();
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
await user.type(screen.getByLabelText("Message to continue this session…"), "hi");
await user.click(screen.getByTestId("session-replay-send"));
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-9-1", 9));
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-9-1", "hi"));
});
it("offers a new session, not a resume, for a tool that cannot resume by id", async () => {
vi.mocked(api.sessions.get).mockResolvedValueOnce({
session: { id: "s-old", tool: "gemini", cwd: "/r" },
} as never);
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={vi.fn()} />);
expect(await screen.findByTestId("session-replay-no-resume")).toBeInTheDocument();
expect(screen.queryByTestId("session-replay-send")).toBeNull();
});
});
@@ -3,8 +3,8 @@
* @description The transcript page's explicit Resume and Fork: it shows only for a
* Claude session inside a workspace, a live session offers its pane instead,
* and a resume asks first (it re-reads the whole transcript), then starts in
* the first free pane slot and opens that pane — or refuses when all four are
* busy; a Fork asks too and starts a new session id beside a live original.
* the free pane slot the server picks (`slot: "auto"`) and opens that pane —
* or says so when all four are busy; a Fork asks too and starts a new session id beside a live original.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -81,7 +81,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript");
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-1",
});
await waitFor(() =>
@@ -97,7 +97,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(4, "start", {
slot: 0,
slot: "auto",
resumeSessionId: "s-2",
});
});
@@ -135,7 +135,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
await user.click(screen.getByRole("button", { name: "Fork session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-1",
forkSession: true,
});
@@ -147,7 +147,7 @@ describe("SessionResumeButton", () => {
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
slot: "auto",
resumeSessionId: "s-wt",
});
});
@@ -178,7 +178,12 @@ describe("SessionResumeButton", () => {
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
);
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: 2, resumeSessionId: "s-1" });
// The server picks the free tab under its lock; the client only re-reads
// to open a pane that took this session meanwhile.
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: "auto",
resumeSessionId: "s-1",
});
});
it("starts once on a double click while the panes are being re-read", async () => {
@@ -216,16 +221,31 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
});
it("refuses when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 3, 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();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(api.lanes.action).not.toHaveBeenCalled();
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
});
it("opens the pane the server names when the session is already live there", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
// A nested workspace's pane holds it: opened in THAT workspace.
Object.assign(new Error("open"), { code: "ESESSIONLIVE", runId: "agenthub-lane-4-3" })
);
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=4&run=agenthub-lane-4-3"
)
);
});
it("shows nothing outside a workspace or for a non-Claude session", async () => {
@@ -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 () => {