Files
kandev/apps/web/components/task/preview-session-tabs.test.tsx
T

1189 lines
40 KiB
TypeScript

/* eslint-disable max-lines -- preview lifecycle and Plan-tab contracts share one store harness. */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import {
act,
cleanup,
fireEvent,
render,
renderHook,
screen,
waitFor,
} from "@testing-library/react";
import { createStore } from "zustand/vanilla";
import { useStore } from "zustand";
import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { TaskPlan } from "@/lib/types/http-agents";
const mocks = vi.hoisted(() => ({
taskChatPanelProps: null as null | Record<string, unknown>,
taskLaunchErrorProviderValue: null as null | Record<string, unknown>,
sessions: [] as TaskSession[],
agentProfiles: [] as AgentProfileOption[],
primarySessionId: null as string | null,
kanbanTasks: [{ id: "task-1", primarySessionId: null as string | null }],
kanbanSnapshots: {} as Record<
string,
{ tasks: Array<{ id: string; primarySessionId: string | null }> }
>,
setPrimary: vi.fn(),
stop: vi.fn(),
resume: vi.fn(),
remove: vi.fn(async (_sessionId?: string, _options?: unknown) => true),
renameSession: vi.fn(async () => undefined),
upsertTaskSessionFromEvent: vi.fn(),
taskSessionItems: {} as Record<string, TaskSession>,
useTaskSessions: vi.fn(),
useSessionResumption: vi.fn(),
taskStatusSummary: undefined as unknown,
markTaskPlanSeen: vi.fn(),
setTaskPlan: vi.fn(),
setTaskPlanLoading: vi.fn(),
getTaskPlan: vi.fn(),
}));
vi.mock("./task-chat-panel", () => ({
TaskChatPanel: (props: Record<string, unknown>) => {
mocks.taskChatPanelProps = props;
return <div data-testid="preview-chat" />;
},
}));
vi.mock("./passthrough-toolbar", () => ({
PassthroughToolbar: () => <div data-testid="preview-passthrough-toolbar" />,
}));
vi.mock("@/components/task/chat/session-bootstrap-recovery-card", () => ({
SessionBootstrapRecoveryCard: ({
error,
automaticRecovery,
}: {
error: { stamp: string };
automaticRecovery?: { recoveryFailure: { outcome: string } | null };
}) => (
<div data-testid="preview-bootstrap-recovery-card">
{error.stamp}
{automaticRecovery?.recoveryFailure?.outcome ?? ""}
</div>
),
}));
vi.mock("./task-launch-error-context", () => ({
TaskLaunchErrorProvider: ({
value,
children,
}: {
value: Record<string, unknown>;
children: ReactNode;
}) => {
mocks.taskLaunchErrorProviderValue = value;
return <>{children}</>;
},
}));
vi.mock("@/hooks/use-task-sessions", () => ({
useTaskSessions: mocks.useTaskSessions,
}));
vi.mock("@/hooks/domains/session/use-session-resumption", () => ({
useSessionResumption: mocks.useSessionResumption,
}));
vi.mock("@/hooks/domains/task/use-task-status-summary", () => ({
useTaskStatusSummary: () => mocks.taskStatusSummary,
}));
vi.mock("@/lib/api/domains/plan-api", () => ({
getTaskPlan: mocks.getTaskPlan,
}));
vi.mock("@/components/editors/tiptap/tiptap-plan-readonly", () => ({
PlanReadOnlyMarkdown: (props: { content: string; testId?: string }) => (
<div data-testid={props.testId}>{props.content}</div>
),
}));
vi.mock("@/hooks/domains/session/use-session-actions", () => ({
useSessionActions: ({
sessionId,
onDeleted,
}: {
sessionId?: string;
onDeleted?: () => void;
}) => ({
setPrimary: () => mocks.setPrimary(sessionId),
stop: () => mocks.stop(sessionId),
resume: () => mocks.resume(sessionId),
remove: async (options?: unknown) => {
const ok = await mocks.remove(sessionId, options);
if (ok) onDeleted?.();
return ok;
},
}),
isSessionStoppable: (s: string) =>
s === "RUNNING" || s === "STARTING" || s === "WAITING_FOR_INPUT",
isSessionDeletable: (s: string) => s !== "RUNNING" && s !== "STARTING",
isSessionResumable: (s: string) => s === "COMPLETED" || s === "FAILED" || s === "CANCELLED",
}));
vi.mock("@/components/agent-logo", () => ({
AgentLogo: ({ agentName }: { agentName: string }) => (
<span data-testid={`agent-logo-${agentName}`} />
),
}));
vi.mock("@/components/task/handoff-profile-menu-items", () => ({
HandoffContextMenuSub: () => (
<div role="menuitem" data-testid="session-handoff-submenu">
Handoff
</div>
),
}));
vi.mock("@/components/task/share/share-dialog", () => ({
ShareDialog: () => null,
}));
vi.mock("@/components/task/new-session-dialog", () => ({
NewSessionDialog: () => null,
}));
vi.mock("@/lib/api/domains/session-api", () => ({
renameSession: mocks.renameSession,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn(), updateToast: vi.fn() }),
}));
// Real TabRenameInput auto-focuses on mount inside a useEffect, which races
// against Radix's context-menu-close focus restoration under jsdom's
// synchronous focus/blur emulation (not reproducible in a real browser —
// the identical pattern already ships for terminal tabs). Stubbing it keeps
// this suite testing the preview's own wiring (open → commit through the
// shared rename hook), not TabRenameInput's unrelated focus management.
vi.mock("./tab-rename-input", () => ({
TabRenameInput: ({
initial,
onCommit,
testId,
}: {
initial: string;
onCommit: (next: string) => void;
testId?: string;
}) => {
let value = initial;
return (
<input
data-testid={testId}
defaultValue={initial}
onChange={(e) => {
value = e.target.value;
}}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit(value);
}}
/>
);
},
}));
type TaskPlansState = {
byTaskId: Record<string, TaskPlan | null>;
loadedByTaskId: Record<string, boolean>;
loadingByTaskId: Record<string, boolean>;
lastSeenUpdatedAtByTaskId: Record<string, string | undefined>;
};
type FakeAppState = {
agentProfiles: { items: AgentProfileOption[] };
connection: { status: "connected" | "disconnected" };
taskPlans: TaskPlansState;
markTaskPlanSeen: (taskId: string) => void;
setTaskPlan: (taskId: string, plan: TaskPlan | null) => void;
setTaskPlanLoading: (taskId: string, loading: boolean) => void;
};
function emptyTaskPlans(): TaskPlansState {
return { byTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {}, lastSeenUpdatedAtByTaskId: {} };
}
// A real (non-static) store double: production code drives loading/loaded
// state through these actions, and components re-render on real state
// changes, exactly like the app's actual Zustand store. This is required to
// exercise effect-retrigger regressions (F1/F2) that a frozen mock object
// cannot reproduce.
const fakeStore = createStore<FakeAppState>()((set, get) => ({
agentProfiles: { items: [] },
connection: { status: "connected" },
taskPlans: emptyTaskPlans(),
markTaskPlanSeen: (taskId: string) => {
mocks.markTaskPlanSeen(taskId);
const plan = get().taskPlans.byTaskId[taskId];
set((state) => ({
taskPlans: {
...state.taskPlans,
lastSeenUpdatedAtByTaskId: {
...state.taskPlans.lastSeenUpdatedAtByTaskId,
[taskId]: plan?.updated_at ?? "",
},
},
}));
},
setTaskPlan: (taskId: string, plan: TaskPlan | null) => {
mocks.setTaskPlan(taskId, plan);
set((state) => ({
taskPlans: {
...state.taskPlans,
byTaskId: { ...state.taskPlans.byTaskId, [taskId]: plan },
loadingByTaskId: { ...state.taskPlans.loadingByTaskId, [taskId]: false },
loadedByTaskId: { ...state.taskPlans.loadedByTaskId, [taskId]: true },
},
}));
},
setTaskPlanLoading: (taskId: string, loading: boolean) => {
mocks.setTaskPlanLoading(taskId, loading);
set((state) => ({
taskPlans: {
...state.taskPlans,
loadingByTaskId: { ...state.taskPlans.loadingByTaskId, [taskId]: loading },
},
}));
},
}));
function setTaskPlansState(patch: Partial<TaskPlansState>) {
fakeStore.setState((state) => ({ taskPlans: { ...state.taskPlans, ...patch } }));
}
type TestAppState = FakeAppState & {
kanban: {
tasks: Array<{ id: string; primarySessionId: string | null }>;
};
kanbanMulti: {
snapshots: Record<string, { tasks: Array<{ id: string; primarySessionId: string | null }> }>;
};
};
const appStoreApi = {
getState: () => ({
...fakeStore.getState(),
taskSessions: { items: mocks.taskSessionItems },
upsertTaskSessionFromEvent: mocks.upsertTaskSessionFromEvent,
}),
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: TestAppState) => unknown) =>
useStore(fakeStore, (state) =>
selector({
...state,
agentProfiles: { items: mocks.agentProfiles },
kanban: {
tasks: mocks.kanbanTasks.map((task) => ({
...task,
primarySessionId: mocks.primarySessionId ?? task.primarySessionId,
})),
},
kanbanMulti: { snapshots: mocks.kanbanSnapshots },
}),
),
useAppStoreApi: () => appStoreApi,
}));
import { PreviewSessionBody, PreviewSessionTabs } from "./preview-session-tabs";
import { usePreviewPlanSummary } from "./preview-plan-panel";
const TASK_ID = "task-1";
const TASK_ID_B = "task-2";
const TIMESTAMP = "2026-07-21T00:00:00Z";
const START_TIME = TIMESTAMP;
const LATER_TIMESTAMP = "2026-07-22T00:00:00Z";
const SESSION_A_TAB_TESTID = "preview-session-tab-session-a";
const PLAN_INDICATOR_TESTID = "preview-plan-tab-indicator";
const PLAN_TAB_TESTID = "preview-plan-tab";
const PLAN_PANEL_TESTID = "preview-plan-panel";
const PLAN_ERROR_TESTID = "preview-plan-error-state";
function makeSession(id: string, overrides: Partial<TaskSession> = {}): TaskSession {
return {
id,
task_id: TASK_ID,
state: "COMPLETED",
started_at: START_TIME,
updated_at: START_TIME,
...overrides,
} as TaskSession;
}
const session: TaskSession = {
id: toSessionId("session-1"),
task_id: toTaskId(TASK_ID),
state: "COMPLETED",
started_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
afterEach(() => {
cleanup();
mocks.taskChatPanelProps = null;
mocks.taskLaunchErrorProviderValue = null;
mocks.sessions = [];
mocks.agentProfiles = [];
mocks.primarySessionId = null;
mocks.kanbanTasks = [{ id: "task-1", primarySessionId: null }];
mocks.kanbanSnapshots = {};
mocks.setPrimary.mockClear();
mocks.stop.mockClear();
mocks.resume.mockClear();
mocks.remove.mockClear();
mocks.remove.mockImplementation(async () => true);
mocks.renameSession.mockClear();
mocks.upsertTaskSessionFromEvent.mockClear();
mocks.taskSessionItems = {};
mocks.useTaskSessions.mockReset();
mocks.useTaskSessions.mockImplementation(() => ({ sessions: mocks.sessions, isLoaded: true }));
mocks.useSessionResumption.mockReset();
mocks.useSessionResumption.mockImplementation(() => ({
error: null,
notice: null,
resumeSession: vi.fn(),
}));
mocks.taskStatusSummary = undefined;
mocks.getTaskPlan.mockReset();
mocks.getTaskPlan.mockResolvedValue(null);
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
describe("PreviewSessionBody delivery", () => {
it("uses TaskChatPanel's queue-aware shared delivery path", () => {
render(<PreviewSessionBody session={session} taskId={TASK_ID} />);
expect(mocks.taskChatPanelProps).toMatchObject({
sessionId: "session-1",
taskId: TASK_ID,
hideSessionsDropdown: true,
});
expect(mocks.taskChatPanelProps).not.toHaveProperty("onSend");
});
it("passes automatic recovery results to the session-owned preview provider", () => {
const recovery = {
resumptionState: "error" as const,
error: "Session recovery failed",
notice: null,
recoveryFailure: {
outcome: "recovery_failed" as const,
resumeError: "raw resume failure",
restoreError: "raw restore failure",
},
resumeSession: vi.fn(),
};
render(<PreviewSessionBody session={session} taskId={TASK_ID} resumption={recovery} />);
expect(mocks.taskLaunchErrorProviderValue?.automaticRecovery).toBe(recovery);
});
it("keeps the bootstrap recovery card and automatic outcome in passthrough preview", () => {
mocks.sessions = [
makeSession("session-1", {
is_passthrough: true,
metadata: {
last_agent_error: {
message: "The agent could not start.",
occurred_at: TIMESTAMP,
stamp: "preview-bootstrap-1",
phase: "bootstrap",
},
},
}),
];
mocks.useTaskSessions.mockReturnValue({ sessions: mocks.sessions, isLoaded: true });
mocks.taskStatusSummary = {
active_error: {
session_id: "session-1",
stamp: "preview-bootstrap-1",
occurred_at: TIMESTAMP,
preview: "The agent could not start.",
phase: "bootstrap",
},
};
const recovery = {
resumptionState: "error" as const,
error: "Session recovery failed",
notice: null,
recoveryFailure: {
outcome: "recovery_failed" as const,
resumeError: "raw resume failure",
restoreError: "raw restore failure",
},
resumeSession: vi.fn(),
};
mocks.useSessionResumption.mockReturnValue(recovery);
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-1" />);
expect(screen.getByTestId("preview-bootstrap-recovery-card").textContent).toContain(
"recovery_failed",
);
expect(screen.getByTestId("preview-passthrough-toolbar")).toBeTruthy();
});
});
describe("PreviewSessionTabs tab label", () => {
it("shows the session's custom name instead of the agent-derived label", () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED", name: "My renamed agent" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
expect(screen.getByTestId(SESSION_A_TAB_TESTID).textContent).toContain("My renamed agent");
});
it("passes the owning task archive state to session recovery", () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" isArchived />);
expect(mocks.useSessionResumption).toHaveBeenCalledWith(TASK_ID, "session-a", true);
});
});
describe("PreviewSessionTabs session context menu", () => {
it("renders the full lifecycle menu on right-click, without Close Others", () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
expect(screen.getByRole("menuitem", { name: "Rename…" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Set as Primary" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Resume" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Share" })).toBeTruthy();
expect(screen.getByTestId("session-handoff-submenu")).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Close Others" })).toBeNull();
});
it("disables Set as Primary when the session is already primary", () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
mocks.primarySessionId = "session-a";
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
expect(
screen.getByRole("menuitem", { name: "Set as Primary" }).getAttribute("aria-disabled"),
).toBe("true");
});
it("uses the latest primary session from a multi-workflow snapshot", () => {
mocks.sessions = [
makeSession("session-a", { state: "RUNNING" }),
makeSession("session-b", { state: "RUNNING" }),
];
mocks.kanbanTasks = [];
mocks.kanbanSnapshots = {
"workflow-2": { tasks: [{ id: TASK_ID, primarySessionId: "session-a" }] },
};
const { rerender } = render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-b" />);
mocks.kanbanSnapshots = {
"workflow-2": { tasks: [{ id: TASK_ID, primarySessionId: "session-b" }] },
};
rerender(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-b" />);
fireEvent.contextMenu(screen.getByTestId("preview-session-tab-session-b"));
expect(
screen.getByRole("menuitem", { name: "Set as Primary" }).getAttribute("aria-disabled"),
).toBe("true");
});
it("shows Stop (not Resume) and hides Delete for a running session", () => {
mocks.sessions = [makeSession("session-a", { state: "RUNNING" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
expect(screen.getByRole("menuitem", { name: "Stop" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Resume" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Delete" })).toBeNull();
});
it("opens the inline rename input and commits through the shared rename hook", async () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
mocks.taskSessionItems = { "session-a": mocks.sessions[0] };
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
fireEvent.click(screen.getByRole("menuitem", { name: "Rename…" }));
const input = await screen.findByTestId("preview-session-tab-rename-input");
fireEvent.change(input, { target: { value: "renamed session" } });
fireEvent.keyDown(input, { key: "Enter" });
await vi.waitFor(() =>
expect(mocks.renameSession).toHaveBeenCalledWith("session-a", "renamed session"),
);
expect(screen.queryByTestId("preview-session-tab-rename-input")).toBeNull();
});
it("opens the delete confirmation popover and calls session.delete on confirm", async () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-a" />);
fireEvent.contextMenu(screen.getByTestId(SESSION_A_TAB_TESTID));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
const confirm = await screen.findByTestId("session-delete-confirm");
fireEvent.click(confirm);
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
});
});
async function deleteViaContextMenu(tabTestId: string) {
fireEvent.contextMenu(screen.getByTestId(tabTestId));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
const confirm = await screen.findByTestId("session-delete-confirm");
fireEvent.click(confirm);
}
/** Makes `mocks.remove` hang until the returned resolver is called, so a
* test can change props (simulate the user switching tabs) while a
* `session.delete` round-trip is still in flight. */
function deferDelete() {
let resolveDelete: (ok: boolean) => void = () => {};
mocks.remove.mockImplementation(
() =>
new Promise<boolean>((resolve) => {
resolveDelete = resolve;
}),
);
return {
settle: async (ok: boolean) => {
await act(async () => {
resolveDelete(ok);
await Promise.resolve();
await Promise.resolve();
});
},
};
}
describe("PreviewSessionTabs delete session selection", () => {
it("does not change the viewed session when a non-active tab is deleted", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
makeSession("session-c", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-b"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu("preview-session-tab-session-a");
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
expect(onSessionChange).not.toHaveBeenCalled();
});
it("re-points to a remaining session when the active tab is deleted", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu("preview-session-tab-session-a");
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
expect(onSessionChange).toHaveBeenCalledWith("session-b");
});
it("calls onSessionChange with null when the last remaining session is deleted", async () => {
mocks.sessions = [makeSession("session-a", { state: "COMPLETED" })];
const onSessionChange = vi.fn();
render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu(SESSION_A_TAB_TESTID);
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
expect(onSessionChange).toHaveBeenCalledWith(null);
});
});
describe("PreviewSessionTabs deferred delete", () => {
it("does not redirect when the active tab changes while its own delete is still pending", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
makeSession("session-c", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
const pendingDelete = deferDelete();
const { rerender } = render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu(SESSION_A_TAB_TESTID);
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-a", { feedback: "toast" }),
);
// User switches away from the tab being deleted before session.delete settles.
rerender(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-b"
onSessionChange={onSessionChange}
/>,
);
await pendingDelete.settle(true);
expect(onSessionChange).not.toHaveBeenCalled();
});
it("redirects once the pending delete resolves for a tab that became active in the meantime", async () => {
mocks.sessions = [
makeSession("session-a", { state: "COMPLETED" }),
makeSession("session-b", { state: "COMPLETED" }),
];
const onSessionChange = vi.fn();
const pendingDelete = deferDelete();
const { rerender } = render(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-a"
onSessionChange={onSessionChange}
/>,
);
await deleteViaContextMenu("preview-session-tab-session-b");
await vi.waitFor(() =>
expect(mocks.remove).toHaveBeenCalledWith("session-b", { feedback: "toast" }),
);
// User switches to the tab now pending deletion before session.delete settles.
rerender(
<PreviewSessionTabs
taskId={TASK_ID}
sessionId="session-b"
onSessionChange={onSessionChange}
/>,
);
await pendingDelete.settle(true);
expect(onSessionChange).toHaveBeenCalledWith("session-a");
});
});
function agentPlan(overrides: Partial<TaskPlan> = {}): TaskPlan {
return {
id: "plan-1",
task_id: TASK_ID,
title: "Plan",
content: "## Plan content",
created_by: "agent",
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
...overrides,
};
}
describe("PreviewSessionTabs Plan tab", () => {
beforeEach(() => {
mocks.useTaskSessions.mockReturnValue({ sessions: [session], isLoaded: true });
mocks.useSessionResumption.mockReturnValue({
error: null,
notice: null,
resumeSession: vi.fn(),
});
mocks.getTaskPlan.mockResolvedValue(null);
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
afterEach(() => {
cleanup();
mocks.useTaskSessions.mockReset();
mocks.useSessionResumption.mockReset();
mocks.markTaskPlanSeen.mockReset();
mocks.getTaskPlan.mockReset();
});
it("shows a Plan tab alongside session tabs", () => {
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(screen.getByTestId(PLAN_TAB_TESTID)).toBeTruthy();
expect(screen.getByTestId(`preview-session-tab-${session.id}`)).toBeTruthy();
});
it("clicking the Plan tab swaps the chat body for the read-only plan and does not change the session", () => {
setTaskPlansState({
byTaskId: { [TASK_ID]: agentPlan() },
loadedByTaskId: { [TASK_ID]: true },
});
const onSessionChange = vi.fn();
render(
<PreviewSessionTabs taskId={TASK_ID} sessionId={null} onSessionChange={onSessionChange} />,
);
expect(screen.getByTestId("preview-chat")).toBeTruthy();
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
expect(screen.queryByTestId("preview-chat")).toBeNull();
expect(screen.getByTestId(PLAN_PANEL_TESTID)).toBeTruthy();
expect(screen.getByText("## Plan content")).toBeTruthy();
expect(onSessionChange).not.toHaveBeenCalled();
});
it("shows the unseen indicator for an agent-authored plan the user hasn't seen", () => {
setTaskPlansState({
byTaskId: { [TASK_ID]: agentPlan() },
loadedByTaskId: { [TASK_ID]: true },
});
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(screen.getByTestId(PLAN_INDICATOR_TESTID)).toBeTruthy();
});
it("does not show the indicator for a user-authored plan", () => {
setTaskPlansState({
byTaskId: { [TASK_ID]: agentPlan({ created_by: "user" }) },
loadedByTaskId: { [TASK_ID]: true },
});
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(screen.queryByTestId(PLAN_INDICATOR_TESTID)).toBeNull();
});
it("does not show the indicator once the plan's updated_at has been seen", () => {
setTaskPlansState({
byTaskId: { [TASK_ID]: agentPlan() },
loadedByTaskId: { [TASK_ID]: true },
lastSeenUpdatedAtByTaskId: { [TASK_ID]: TIMESTAMP },
});
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(screen.queryByTestId(PLAN_INDICATOR_TESTID)).toBeNull();
});
it("shows the Plan tab and renders the plan when the task has no sessions", () => {
mocks.useTaskSessions.mockReturnValue({ sessions: [], isLoaded: true });
setTaskPlansState({
byTaskId: { [TASK_ID]: agentPlan() },
loadedByTaskId: { [TASK_ID]: true },
});
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(screen.getByTestId(PLAN_TAB_TESTID)).toBeTruthy();
expect(screen.getByTestId(PLAN_PANEL_TESTID)).toBeTruthy();
expect(screen.queryByTestId("preview-empty-state")).toBeNull();
expect(mocks.markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID);
expect(screen.queryByTestId(PLAN_INDICATOR_TESTID)).toBeNull();
});
it("uses a touch-sized tab list for coarse-pointer previews", () => {
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
const list = screen
.getByTestId("preview-session-tabs")
.querySelector('[data-slot="tabs-list"]');
expect(list).not.toBeNull();
expect(list?.className).toContain("[@media(pointer:coarse)]:!h-11");
});
it("clears the indicator (marks seen) when the Plan tab is clicked", () => {
setTaskPlansState({
byTaskId: { [TASK_ID]: agentPlan() },
loadedByTaskId: { [TASK_ID]: true },
});
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(screen.getByTestId(PLAN_INDICATOR_TESTID)).toBeTruthy();
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
expect(mocks.markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID);
});
});
describe("PreviewSessionTabs Plan tab session loading", () => {
beforeEach(() => {
mocks.useTaskSessions.mockReturnValue({ sessions: [], isLoaded: false });
mocks.useSessionResumption.mockReturnValue({
error: null,
notice: null,
resumeSession: vi.fn(),
});
mocks.getTaskPlan.mockResolvedValue(null);
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
afterEach(() => {
cleanup();
mocks.useTaskSessions.mockReset();
mocks.useSessionResumption.mockReset();
mocks.markTaskPlanSeen.mockReset();
mocks.getTaskPlan.mockReset();
});
it("does not mark a plan seen while the session list is still loading", () => {
setTaskPlansState({
byTaskId: { [TASK_ID]: agentPlan() },
loadedByTaskId: { [TASK_ID]: true },
});
const { rerender } = render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(mocks.markTaskPlanSeen).not.toHaveBeenCalled();
mocks.useTaskSessions.mockReturnValue({ sessions: [session], isLoaded: true });
rerender(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
expect(screen.getByTestId(PLAN_INDICATOR_TESTID)).toBeTruthy();
});
});
describe("PreviewSessionTabs Plan tab reliability", () => {
beforeEach(() => {
mocks.useTaskSessions.mockReturnValue({ sessions: [session], isLoaded: true });
mocks.useSessionResumption.mockReturnValue({
error: null,
notice: null,
resumeSession: vi.fn(),
});
mocks.getTaskPlan.mockResolvedValue(null);
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
afterEach(() => {
cleanup();
mocks.useTaskSessions.mockReset();
mocks.useSessionResumption.mockReset();
mocks.markTaskPlanSeen.mockReset();
mocks.getTaskPlan.mockReset();
});
it("stops retrying the plan fetch after it rejects and shows an error state", async () => {
mocks.getTaskPlan.mockRejectedValue(new Error("boom"));
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
await waitFor(() => {
expect(screen.getByTestId(PLAN_ERROR_TESTID)).toBeTruthy();
});
expect(mocks.getTaskPlan).toHaveBeenCalledTimes(1);
// A retry-loop regression would call getTaskPlan again on every one of
// these render/microtask cycles.
await act(async () => {
for (let i = 0; i < 5; i += 1) await Promise.resolve();
});
expect(mocks.getTaskPlan).toHaveBeenCalledTimes(1);
});
it("retries the plan fetch for a task revisited after a failure", async () => {
// `PreviewSessionTabs` is reused across tasks without remounting, so a
// failure for TASK_ID must survive switching the preview to TASK_ID_B and
// back. Only TASK_ID's fetch rejects (once); TASK_ID_B always resolves.
mocks.getTaskPlan.mockImplementation((id: string) =>
id === TASK_ID ? Promise.reject(new Error("boom")) : Promise.resolve(null),
);
const { rerender } = render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
await waitFor(() => {
expect(screen.getByTestId(PLAN_ERROR_TESTID)).toBeTruthy();
});
expect(mocks.getTaskPlan.mock.calls.filter(([id]) => id === TASK_ID)).toHaveLength(1);
// Switch away to another task, then back to the failed one.
act(() => {
rerender(<PreviewSessionTabs taskId={TASK_ID_B} sessionId={null} />);
});
mocks.getTaskPlan.mockImplementation((id: string) =>
id === TASK_ID ? Promise.resolve(agentPlan()) : Promise.resolve(null),
);
act(() => {
rerender(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
});
// Re-selecting the Plan tab is the retry signal.
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
await waitFor(() => {
expect(screen.getByTestId(PLAN_PANEL_TESTID)).toBeTruthy();
});
expect(mocks.getTaskPlan.mock.calls.filter(([id]) => id === TASK_ID)).toHaveLength(2);
});
});
describe("PreviewSessionTabs Plan tab recovery", () => {
beforeEach(() => {
mocks.useTaskSessions.mockReturnValue({ sessions: [session], isLoaded: true });
mocks.useSessionResumption.mockReturnValue({
error: null,
notice: null,
resumeSession: vi.fn(),
});
mocks.getTaskPlan.mockResolvedValue(null);
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
afterEach(() => {
cleanup();
mocks.useTaskSessions.mockReset();
mocks.useSessionResumption.mockReset();
mocks.markTaskPlanSeen.mockReset();
mocks.getTaskPlan.mockReset();
});
it("retries a failed plan fetch after the WebSocket reconnects", async () => {
mocks.getTaskPlan.mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(agentPlan());
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
await waitFor(() => {
expect(screen.getByTestId(PLAN_ERROR_TESTID)).toBeTruthy();
});
act(() => {
fakeStore.setState({ connection: { status: "disconnected" } });
});
act(() => {
fakeStore.setState({ connection: { status: "connected" } });
});
await waitFor(() => {
expect(screen.getByTestId(PLAN_PANEL_TESTID)).toBeTruthy();
});
expect(mocks.getTaskPlan).toHaveBeenCalledTimes(2);
});
it("shows an authoritative WebSocket plan after an earlier fetch failure", async () => {
mocks.getTaskPlan.mockRejectedValue(new Error("boom"));
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
await waitFor(() => {
expect(screen.getByTestId(PLAN_ERROR_TESTID)).toBeTruthy();
});
act(() => {
fakeStore.getState().setTaskPlan(TASK_ID, agentPlan({ updated_at: LATER_TIMESTAMP }));
});
expect(screen.getByTestId(PLAN_PANEL_TESTID)).toBeTruthy();
expect(screen.queryByTestId(PLAN_ERROR_TESTID)).toBeNull();
});
it("retries a failed plan fetch from the error state", async () => {
mocks.getTaskPlan.mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(agentPlan());
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
await waitFor(() => {
expect(screen.getByTestId("preview-plan-retry")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("preview-plan-retry"));
await waitFor(() => {
expect(screen.getByTestId(PLAN_PANEL_TESTID)).toBeTruthy();
});
expect(mocks.getTaskPlan).toHaveBeenCalledTimes(2);
});
});
describe("PreviewSessionTabs Plan tab indicator timing", () => {
beforeEach(() => {
mocks.useTaskSessions.mockReturnValue({ sessions: [session], isLoaded: true });
mocks.useSessionResumption.mockReturnValue({
error: null,
notice: null,
resumeSession: vi.fn(),
});
mocks.getTaskPlan.mockResolvedValue(null);
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
afterEach(() => {
cleanup();
mocks.useTaskSessions.mockReset();
mocks.useSessionResumption.mockReset();
mocks.markTaskPlanSeen.mockReset();
mocks.getTaskPlan.mockReset();
});
it("does not re-light the indicator when the plan finishes loading while the Plan tab is already open", async () => {
mocks.getTaskPlan.mockResolvedValue(agentPlan());
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
await waitFor(() => {
expect(screen.getByTestId(PLAN_PANEL_TESTID)).toBeTruthy();
});
expect(screen.queryByTestId(PLAN_INDICATOR_TESTID)).toBeNull();
});
it("does not re-light the indicator when the plan updates over WS while the Plan tab is open", () => {
const initialPlan = agentPlan();
setTaskPlansState({
byTaskId: { [TASK_ID]: initialPlan },
loadedByTaskId: { [TASK_ID]: true },
lastSeenUpdatedAtByTaskId: { [TASK_ID]: initialPlan.updated_at },
});
render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
expect(screen.queryByTestId(PLAN_INDICATOR_TESTID)).toBeNull();
// Simulate a task.plan.updated WS event landing for this task while the
// Plan tab is already the active view.
const updatedPlan = agentPlan({ updated_at: LATER_TIMESTAMP });
act(() => {
setTaskPlansState({ byTaskId: { [TASK_ID]: updatedPlan } });
});
expect(screen.queryByTestId(PLAN_INDICATOR_TESTID)).toBeNull();
});
it("does not mark the next task's plan seen when switching the previewed task while Plan is open", () => {
setTaskPlansState({
byTaskId: {
[TASK_ID]: agentPlan(),
[TASK_ID_B]: agentPlan({
id: "plan-2",
task_id: TASK_ID_B,
updated_at: LATER_TIMESTAMP,
}),
},
loadedByTaskId: { [TASK_ID]: true, [TASK_ID_B]: true },
});
const { rerender } = render(<PreviewSessionTabs taskId={TASK_ID} sessionId={null} />);
fireEvent.mouseDown(screen.getByTestId(PLAN_TAB_TESTID), { button: 0 });
mocks.markTaskPlanSeen.mockReset();
act(() => {
rerender(<PreviewSessionTabs taskId={TASK_ID_B} sessionId={null} />);
});
expect(mocks.markTaskPlanSeen).not.toHaveBeenCalledWith(TASK_ID_B);
expect(fakeStore.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID_B]).toBeUndefined();
expect(screen.queryByTestId(PLAN_INDICATOR_TESTID)).toBeTruthy();
});
});
describe("usePreviewPlanSummary fetch race", () => {
beforeEach(() => {
fakeStore.setState({ taskPlans: emptyTaskPlans(), connection: { status: "connected" } });
});
afterEach(() => {
mocks.getTaskPlan.mockReset();
mocks.setTaskPlan.mockReset();
});
it("does not overwrite a newer WS-pushed plan with a stale null fetch response", async () => {
let resolveFetch: (value: TaskPlan | null) => void = () => {};
mocks.getTaskPlan.mockReturnValue(
new Promise<TaskPlan | null>((resolve) => {
resolveFetch = resolve;
}),
);
renderHook(() => usePreviewPlanSummary(TASK_ID));
const newerPlan = agentPlan({ updated_at: LATER_TIMESTAMP });
act(() => {
fakeStore.getState().setTaskPlan(TASK_ID, newerPlan);
});
await act(async () => {
resolveFetch(null);
await Promise.resolve();
});
expect(fakeStore.getState().taskPlans.byTaskId[TASK_ID]).toEqual(newerPlan);
expect(fakeStore.getState().taskPlans.loadingByTaskId[TASK_ID]).toBe(false);
});
it("does not overwrite a newer WS-pushed plan with a stale, older fetch response", async () => {
let resolveFetch: (value: TaskPlan | null) => void = () => {};
mocks.getTaskPlan.mockReturnValue(
new Promise<TaskPlan | null>((resolve) => {
resolveFetch = resolve;
}),
);
renderHook(() => usePreviewPlanSummary(TASK_ID));
const newerPlan = agentPlan({ updated_at: LATER_TIMESTAMP });
act(() => {
fakeStore.getState().setTaskPlan(TASK_ID, newerPlan);
});
const staleFetchedPlan = agentPlan({ updated_at: TIMESTAMP });
await act(async () => {
resolveFetch(staleFetchedPlan);
await Promise.resolve();
});
expect(fakeStore.getState().taskPlans.byTaskId[TASK_ID]).toEqual(newerPlan);
expect(fakeStore.getState().taskPlans.loadingByTaskId[TASK_ID]).toBe(false);
});
it("does not resurrect a plan deleted over WS while the fetch was in flight", async () => {
let resolveFetch: (value: TaskPlan | null) => void = () => {};
mocks.getTaskPlan.mockReturnValue(
new Promise<TaskPlan | null>((resolve) => {
resolveFetch = resolve;
}),
);
renderHook(() => usePreviewPlanSummary(TASK_ID));
act(() => {
fakeStore.getState().setTaskPlan(TASK_ID, null);
fakeStore.getState().markTaskPlanSeen(TASK_ID);
});
const staleFetchedPlan = agentPlan({ updated_at: TIMESTAMP });
await act(async () => {
resolveFetch(staleFetchedPlan);
await Promise.resolve();
});
expect(fakeStore.getState().taskPlans.byTaskId[TASK_ID]).toBeNull();
expect(fakeStore.getState().taskPlans.loadingByTaskId[TASK_ID]).toBe(false);
});
});