/** * @file SessionResumeButton.test.tsx * @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 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ĩ */ 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, Route, Routes, useLocation } from "react-router-dom"; import type { Lane } from "../../../lib/types"; import type { RunHandle } from "../../../lib/api"; let lanes: Lane[]; let runs: RunHandle[]; vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { lanes: { list: vi.fn(async () => ({ lanes })), action: vi.fn(async () => ({ ok: true, run_id: "agenthub-lane-3-1" })), }, run: { list: vi.fn(async () => ({ items: runs })) }, }, }; }); import { api } from "../../../lib/api"; import { SessionResumeButton } from "../SessionResumeButton"; const lane = (extra: Partial) => ({ id: 3, cwd: "/r", tool: "claude", run_id: null, session_id: null, ...extra }) as Lane; function Where() { const loc = useLocation(); return {loc.pathname + loc.search}; } function renderButton(session: { id: string; cwd: string | null; tool?: string; lane_id?: number; }) { return render( } /> ); } beforeEach(() => { vi.clearAllMocks(); lanes = [lane({ id: 3, cwd: "/r" }), lane({ id: 4, cwd: "/r/sub" })]; runs = [{ id: "agenthub-lane-3", laneId: 3, slot: 0, status: "running" } as RunHandle]; }); describe("SessionResumeButton", () => { it("asks before resuming, then starts in the first free pane and opens it", async () => { const user = userEvent.setup(); renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); await user.click(await screen.findByRole("button", { name: "Resume" })); expect(api.lanes.action).not.toHaveBeenCalled(); expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript"); await user.click(screen.getByRole("button", { name: "Resume session" })); expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: "auto", resumeSessionId: "s-1", }); await waitFor(() => expect(screen.getByTestId("where").textContent).toBe( "/workspace?lane=3&run=agenthub-lane-3-1" ) ); }); it("resumes in the workspace whose folder IS the session's, not a parent", async () => { const user = userEvent.setup(); renderButton({ id: "s-2", cwd: "/r/sub/", tool: "claude" }); 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: "auto", resumeSessionId: "s-2", }); }); it("opens a live session's pane instead of resuming it", async () => { runs = [ { id: "agenthub-lane-3", laneId: 3, slot: 0, status: "running", sessionId: "s-1", } as RunHandle, ]; const user = userEvent.setup(); renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); await user.click(await screen.findByRole("button", { name: "Open pane" })); expect(api.lanes.action).not.toHaveBeenCalled(); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3"); }); it("forks into a free pane after asking, even while the original has a live pane", async () => { runs = [ { id: "agenthub-lane-3", laneId: 3, slot: 0, status: "running", sessionId: "s-1", } as RunHandle, ]; const user = userEvent.setup(); renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); await user.click(await screen.findByRole("button", { name: "Fork" })); expect(screen.getByRole("dialog")).toHaveTextContent("NEW session"); await user.click(screen.getByRole("button", { name: "Fork session" })); expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: "auto", resumeSessionId: "s-1", forkSession: true, }); }); it("resumes a session in its own worktree through the workspace that owns it", async () => { const user = userEvent.setup(); renderButton({ id: "s-wt", cwd: "/r/.claude/worktrees/ah-3-x", tool: "claude", lane_id: 3 }); 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: "auto", resumeSessionId: "s-wt", }); }); it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => { const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" }); await waitFor(() => expect(api.run.list).toHaveBeenCalled()); expect(screen.queryByRole("button", { name: "Resume" })).toBeNull(); unmount(); runs = [ { id: "agenthub-lane-3", laneId: 3, slot: 0, status: "running", sessionId: "s-9", } as RunHandle, ]; renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" }); expect(await screen.findByRole("button", { name: "Open pane" })).toBeInTheDocument(); }); it("re-reads the panes before starting, so a pane started meanwhile is skipped", async () => { const user = userEvent.setup(); renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); await user.click(await screen.findByRole("button", { name: "Resume" })); runs = [0, 1].map( (slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle ); await user.click(screen.getByRole("button", { name: "Resume session" })); // 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 () => { const user = userEvent.setup(); renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); await user.click(await screen.findByRole("button", { name: "Resume" })); let release: (v: { items: RunHandle[] }) => void = () => undefined; vi.mocked(api.run.list).mockImplementationOnce( () => new Promise((r) => (release = r)) as ReturnType ); const confirm = screen.getByRole("button", { name: "Resume session" }); await user.click(confirm); await user.click(confirm); release({ items: runs }); await waitFor(() => expect(api.lanes.action).toHaveBeenCalled()); await new Promise((r) => setTimeout(r, 50)); expect(api.lanes.action).toHaveBeenCalledTimes(1); }); it("opens the session's pane when it went live meanwhile, starting nothing", async () => { const user = userEvent.setup(); renderButton({ id: "s-1", cwd: "/r", tool: "claude" }); await user.click(await screen.findByRole("button", { name: "Resume" })); runs = [ { id: "agenthub-lane-3-2", laneId: 3, slot: 2, status: "running", sessionId: "s-1", } as RunHandle, ]; await user.click(screen.getByRole("button", { name: "Resume session" })); expect(api.lanes.action).not.toHaveBeenCalled(); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2"); }); it("says so when the server finds all ten sessions in use", 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(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions"); }); 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 () => { const { unmount } = renderButton({ id: "s-1", cwd: "/elsewhere", tool: "claude" }); await waitFor(() => expect(api.lanes.list).toHaveBeenCalled()); expect(screen.queryByRole("button", { name: "Resume" })).toBeNull(); unmount(); renderButton({ id: "s-1", cwd: "/r", tool: "codex" }); await waitFor(() => expect(api.run.list).toHaveBeenCalled()); expect(screen.queryByRole("button", { name: "Resume" })).toBeNull(); }); });