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