Files
kandev/apps/web/components/threads/threads-board.test.tsx
T
2026-09-12 16:54:56 +02:00

617 lines
20 KiB
TypeScript

import {
act,
cleanup,
fireEvent,
render as renderReact,
screen,
waitFor,
} from "@testing-library/react";
import type { ReactElement } from "react";
import { StateProvider } from "@/components/state-provider";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ActiveThread } from "@/lib/threads/active-threads";
const sessionMocks = vi.hoisted(() => {
const startedAt = "2026-08-27T10:00:00Z";
const updatedAt = "2026-08-27T12:00:00Z";
return {
startedAt,
updatedAt,
sessionLists: new Map<string, Array<Record<string, unknown>>>(),
sessionErrors: new Map<string, string | null>(),
sessionLoaded: new Map<string, boolean>(),
sessionLoaders: new Map<string, ReturnType<typeof vi.fn>>(),
useTaskSessions: vi.fn((taskId: string) => {
let sessions = sessionMocks.sessionLists.get(taskId);
if (!sessions) {
sessions = [
{
id: `session-${taskId}`,
task_id: taskId,
state: "RUNNING" as const,
is_primary: true,
started_at: sessionMocks.startedAt,
updated_at: sessionMocks.updatedAt,
},
];
sessionMocks.sessionLists.set(taskId, sessions);
}
return {
sessions,
isLoading: false,
isLoaded: sessionMocks.sessionLoaded.get(taskId) ?? true,
error: sessionMocks.sessionErrors.get(taskId) ?? null,
loadSessions: sessionMocks.sessionLoaders.get(taskId) ?? vi.fn(),
};
}),
};
});
vi.mock("./thread-conversation", () => ({
ThreadConversation: ({ sessionId }: { sessionId: string }) => (
<div data-testid={`thread-conversation-${sessionId}`} />
),
}));
vi.mock("@/hooks/use-task-sessions", () => ({
useTaskSessions: sessionMocks.useTaskSessions,
}));
function render(element: ReactElement) {
return renderReact(element, { wrapper: StateProvider });
}
import { ThreadsBoard } from "./threads-board";
afterEach(() => {
cleanup();
sessionMocks.useTaskSessions.mockClear();
sessionMocks.sessionLists.clear();
sessionMocks.sessionErrors.clear();
sessionMocks.sessionLoaded.clear();
sessionMocks.sessionLoaders.clear();
});
const COLUMN_A = "thread-column-a";
const FOCUSED_ATTR = "data-focused";
const COLUMN_B = "thread-column-b";
const CONVERSATION_A = "thread-conversation-session-a";
const TASK_A = "a";
const PRIMARY_SESSION_A = "session-a-primary";
const BUILDER_SESSION_A = "session-a-builder";
const PRIMARY_CONVERSATION_A = `thread-conversation-${PRIMARY_SESSION_A}`;
const BUILDER_CONVERSATION_A = `thread-conversation-${BUILDER_SESSION_A}`;
function thread(overrides: Partial<ActiveThread> & { taskId: string }): ActiveThread {
return {
title: `Task ${overrides.taskId}`,
workflowId: "wf-1",
workflowName: "Delivery",
stepTitle: "Build",
sessionId: `session-${overrides.taskId}`,
sessionState: "RUNNING",
pendingAction: null,
activeSubagentCount: 0,
queuedPromptCount: 0,
lastActivityAt: "2026-08-27T10:00:00Z",
...overrides,
};
}
describe("ThreadsBoard — basic layout", () => {
// @covers AC-TASKS-THREADS-ACTIONS-003.2, AC-TASKS-THREADS-ACTIONS-004.6
it("focuses the successor when the focused column disappears", async () => {
const view = render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" }), thread({ taskId: "c" })]}
onOpenTask={() => {}}
/>,
);
const opener = screen
.getByTestId(COLUMN_B)
.querySelector<HTMLButtonElement>("[data-thread-task-menu] button")!;
act(() => opener.focus());
view.rerender(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "c" })]}
onOpenTask={() => {}}
/>,
);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByTestId("thread-column-c").querySelector("[data-thread-task-menu] button"),
),
);
});
it("renders one column per active thread, in the order it was given", () => {
render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
onOpenTask={() => {}}
/>,
);
const columns = screen.getAllByTestId(/^thread-column-/);
expect(columns.map((column) => column.getAttribute("data-testid"))).toEqual([
COLUMN_A,
COLUMN_B,
]);
});
it("mounts the live conversation inside each column", () => {
render(<ThreadsBoard threads={[thread({ taskId: "a" })]} onOpenTask={() => {}} />);
expect(screen.getByTestId(CONVERSATION_A)).not.toBeNull();
});
it("keeps thirty task shells mounted without mounting thirty conversations", () => {
const threads = Array.from({ length: 30 }, (_, index) =>
thread({ taskId: `task-${String(index + 1).padStart(2, "0")}` }),
);
render(<ThreadsBoard threads={threads} onOpenTask={() => {}} />);
expect(screen.getAllByTestId(/^thread-column-/)).toHaveLength(30);
expect(screen.getAllByTestId(/^thread-conversation-/)).toHaveLength(1);
const sessionListTaskIds = [
...new Set(sessionMocks.useTaskSessions.mock.calls.map(([taskId]) => taskId)),
];
expect(sessionListTaskIds).toEqual(["task-01", "task-02"]);
});
it("keeps the snap pager through the mobile breakpoint", () => {
render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
onOpenTask={() => {}}
/>,
);
expect(screen.getByTestId("threads-board").className).toContain("md:snap-none");
expect(screen.getByTestId(COLUMN_A).className).toContain("md:w-auto");
expect(screen.getByTestId(COLUMN_A).className).not.toContain("sm:w-auto");
});
it("labels a thread with its task title, workflow and step", () => {
render(
<ThreadsBoard
threads={[thread({ taskId: "a", title: "Fix the flaky test" })]}
onOpenTask={() => {}}
/>,
);
expect(screen.getByText("Fix the flaky test")).not.toBeNull();
expect(screen.getByText("Delivery")).not.toBeNull();
expect(screen.getByText("Build")).not.toBeNull();
});
});
describe("ThreadsBoard — session list loading", () => {
it("shows a local retry after the initial session list fails, then renders after success", async () => {
const retry = vi.fn(async () => {
sessionMocks.sessionErrors.set("a", null);
sessionMocks.sessionLoaded.set("a", true);
});
sessionMocks.sessionErrors.set("a", "service unavailable");
sessionMocks.sessionLoaded.set("a", false);
sessionMocks.sessionLoaders.set("a", retry);
const props = { threads: [thread({ taskId: "a" })], onOpenTask: () => {} };
const { rerender } = render(<ThreadsBoard {...props} />);
expect(screen.getByTestId("thread-session-list-error")).not.toBeNull();
expect(screen.getByRole("button", { name: /retry/i })).not.toBeNull();
expect(screen.queryByTestId(CONVERSATION_A)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await act(async () => {
await retry.mock.results[0]?.value;
});
rerender(<ThreadsBoard {...props} />);
await waitFor(() => {
expect(screen.queryByTestId("thread-session-list-error")).toBeNull();
expect(screen.getByTestId(CONVERSATION_A)).not.toBeNull();
});
});
});
describe("ThreadsBoard — session selection", () => {
it("switches only the selected task column to another session", async () => {
sessionMocks.sessionLists.set("a", [
{
id: PRIMARY_SESSION_A,
task_id: TASK_A,
name: "Planner",
state: "RUNNING",
is_primary: true,
started_at: sessionMocks.startedAt,
updated_at: sessionMocks.updatedAt,
},
{
id: BUILDER_SESSION_A,
task_id: TASK_A,
name: "Builder",
state: "COMPLETED",
is_primary: false,
started_at: "2026-08-27T11:00:00Z",
updated_at: "2026-08-27T13:00:00Z",
},
]);
render(
<ThreadsBoard
threads={[thread({ taskId: TASK_A, sessionId: PRIMARY_SESSION_A })]}
onOpenTask={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId(PRIMARY_CONVERSATION_A)).not.toBeNull();
});
fireEvent.mouseDown(screen.getByRole("tab", { name: /Builder/ }), { button: 0 });
expect(screen.queryByTestId(PRIMARY_CONVERSATION_A)).toBeNull();
expect(screen.getByTestId(BUILDER_CONVERSATION_A)).not.toBeNull();
});
it("applies a valid session deep link to the matching task column", async () => {
sessionMocks.sessionLists.set("a", [
{
id: PRIMARY_SESSION_A,
task_id: TASK_A,
name: "Planner",
state: "RUNNING",
is_primary: true,
started_at: sessionMocks.startedAt,
updated_at: sessionMocks.updatedAt,
},
{
id: BUILDER_SESSION_A,
task_id: TASK_A,
name: "Builder",
state: "COMPLETED",
is_primary: false,
started_at: "2026-08-27T11:00:00Z",
updated_at: "2026-08-27T13:00:00Z",
},
]);
render(
<ThreadsBoard
threads={[thread({ taskId: TASK_A, sessionId: PRIMARY_SESSION_A })]}
focusedTaskId="a"
focusedSessionId={BUILDER_SESSION_A}
onOpenTask={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId(BUILDER_CONVERSATION_A)).not.toBeNull();
});
expect(screen.queryByTestId(PRIMARY_CONVERSATION_A)).toBeNull();
});
it("reports an invalid session deep link after membership loads and falls back locally", async () => {
sessionMocks.sessionLists.set("a", [
{
id: PRIMARY_SESSION_A,
task_id: TASK_A,
state: "RUNNING",
is_primary: true,
started_at: sessionMocks.startedAt,
updated_at: sessionMocks.updatedAt,
},
]);
const onInvalidRequestedSession = vi.fn();
render(
<ThreadsBoard
threads={[thread({ taskId: TASK_A, sessionId: PRIMARY_SESSION_A })]}
focusedTaskId="a"
focusedSessionId="missing-session"
onInvalidRequestedSession={onInvalidRequestedSession}
onOpenTask={() => {}}
/>,
);
await waitFor(() => {
expect(onInvalidRequestedSession).toHaveBeenCalledWith(TASK_A, "missing-session");
expect(screen.getByTestId(PRIMARY_CONVERSATION_A)).not.toBeNull();
});
});
});
describe("ThreadsBoard — status, interaction and empty states", () => {
it("shows plain waiting without presenting it as an agent question", () => {
sessionMocks.sessionLists.set("a", [
{
id: "session-a",
task_id: "a",
state: "WAITING_FOR_INPUT",
is_primary: true,
started_at: sessionMocks.startedAt,
updated_at: sessionMocks.updatedAt,
},
]);
render(
<ThreadsBoard
threads={[thread({ taskId: "a", sessionState: "WAITING_FOR_INPUT" })]}
onOpenTask={() => {}}
/>,
);
expect(screen.getByText("Waiting")).not.toBeNull();
expect(screen.queryByText("Working")).toBeNull();
expect(screen.queryByTestId("thread-status-clarification")).toBeNull();
});
it("shows an explicit question while the session is parked", () => {
render(
<ThreadsBoard
threads={[thread({ taskId: "a", sessionState: "IDLE", pendingAction: "clarification" })]}
onOpenTask={() => {}}
/>,
);
expect(screen.getByText("Question from agent")).not.toBeNull();
});
it("counts subagents and queued prompts only when there are some", () => {
render(
<ThreadsBoard
threads={[
thread({ taskId: "a", activeSubagentCount: 2, queuedPromptCount: 1 }),
thread({ taskId: "b" }),
]}
onOpenTask={() => {}}
/>,
);
expect(screen.getByText("2 subagents")).not.toBeNull();
expect(screen.getByText("1 queued prompt")).not.toBeNull();
expect(screen.queryByText("0 subagents")).toBeNull();
});
it("opens the full task page from the column header", () => {
const onOpenTask = vi.fn();
render(<ThreadsBoard threads={[thread({ taskId: "a" })]} onOpenTask={onOpenTask} />);
fireEvent.click(screen.getByRole("button", { name: "Open task" }));
expect(onOpenTask).toHaveBeenCalledWith("a");
});
it("explains the empty board instead of showing a bare surface", () => {
render(<ThreadsBoard threads={[]} onOpenTask={() => {}} />);
expect(screen.getByTestId("threads-empty-state")).not.toBeNull();
expect(screen.getByText("No agent is working right now")).not.toBeNull();
expect(screen.queryByTestId("threads-board")).toBeNull();
});
it("shows a loading state before the first snapshot lands, not the empty state", () => {
render(<ThreadsBoard threads={[]} isLoading onOpenTask={() => {}} />);
expect(screen.getByText("Loading threads...")).not.toBeNull();
expect(screen.queryByTestId("threads-empty-state")).toBeNull();
});
it("keeps a landed board visible while a background refresh runs", () => {
render(<ThreadsBoard threads={[thread({ taskId: "a" })]} isLoading onOpenTask={() => {}} />);
expect(screen.getByTestId(COLUMN_A)).not.toBeNull();
expect(screen.queryByText("Loading threads...")).toBeNull();
});
});
describe("ThreadsBoard — focusing a column from a deep link", () => {
function scrollSpy() {
const calls: Element[] = [];
Element.prototype.scrollIntoView = function scrollIntoViewStub(this: Element) {
calls.push(this);
} as Element["scrollIntoView"];
return calls;
}
it("marks the requested column and leaves the others alone", () => {
scrollSpy();
render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
onOpenTask={() => {}}
/>,
);
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBe("true");
expect(screen.getByTestId(COLUMN_A).getAttribute(FOCUSED_ATTR)).toBeNull();
});
it("scrolls the requested column into view", () => {
const calls = scrollSpy();
render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
onOpenTask={() => {}}
/>,
);
expect(calls).toEqual([screen.getByTestId(COLUMN_B)]);
});
it("scrolls nothing when no column was requested", () => {
const calls = scrollSpy();
render(<ThreadsBoard threads={[thread({ taskId: "a" })]} onOpenTask={() => {}} />);
expect(calls).toEqual([]);
});
it("still scrolls when the column only arrives with a later snapshot", () => {
const calls = scrollSpy();
const { rerender } = render(
<ThreadsBoard threads={[thread({ taskId: "a" })]} focusedTaskId="b" onOpenTask={() => {}} />,
);
expect(calls).toEqual([]);
rerender(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
onOpenTask={() => {}}
/>,
);
expect(calls).toEqual([screen.getByTestId(COLUMN_B)]);
});
it("does not re-scroll a column that stays focused across updates", () => {
const calls = scrollSpy();
const { rerender } = render(
<ThreadsBoard threads={[thread({ taskId: "b" })]} focusedTaskId="b" onOpenTask={() => {}} />,
);
rerender(
<ThreadsBoard
threads={[thread({ taskId: "b", queuedPromptCount: 3 })]}
focusedTaskId="b"
onOpenTask={() => {}}
/>,
);
expect(calls).toHaveLength(1);
});
});
// @covers AC-TASKS-THREADS-ACTIONS-003.4
describe("ThreadsBoard initial activation", () => {
it.each(["pointerDown", "focusIn"] as const)(
"keeps the requested conversation mounted when %s retires its mark before visibility is ready",
(interaction) => {
render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
focusRequestKey="workspace:b:session-b"
onOpenTask={() => {}}
/>,
);
const conversation = screen.getByTestId("thread-conversation-session-b");
fireEvent[interaction](screen.getByTestId(COLUMN_B));
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
expect(screen.queryByTestId("thread-conversation-session-b")).toBe(conversation);
},
);
});
describe("ThreadsBoard — retiring the deep-link mark", () => {
// @covers AC-TASKS-THREADS-ACTIONS-003.4, AC-TASKS-THREADS-ACTIONS-003.5
it("keeps a consumed URL request retired when its excluded column returns", () => {
const props = { focusRequestKey: "workspace:b:session-b", onOpenTask: () => {} };
const allThreads = [thread({ taskId: "a" }), thread({ taskId: "b" })];
const view = render(<ThreadsBoard {...props} threads={allThreads} focusedTaskId="b" />);
fireEvent.pointerDown(screen.getByTestId(COLUMN_A));
view.rerender(
<ThreadsBoard {...props} threads={[thread({ taskId: "a" })]} focusedTaskId={null} />,
);
view.rerender(<ThreadsBoard {...props} threads={allThreads} focusedTaskId="b" />);
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
expect(screen.queryByTestId(CONVERSATION_A)).not.toBeNull();
expect(screen.queryByTestId("thread-conversation-session-b")).toBeNull();
});
it("honors a new URL request after a consumed request loses its resolved column", () => {
const props = { onOpenTask: () => {} };
const view = render(
<ThreadsBoard
{...props}
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
focusRequestKey="workspace:b:session-b"
/>,
);
fireEvent.pointerDown(screen.getByTestId(COLUMN_A));
view.rerender(
<ThreadsBoard
{...props}
threads={[thread({ taskId: "a" })]}
focusedTaskId={null}
focusRequestKey="workspace:b:session-b"
/>,
);
view.rerender(
<ThreadsBoard
{...props}
threads={[thread({ taskId: "a" })]}
focusedTaskId="a"
focusRequestKey="workspace:a:session-a"
/>,
);
expect(screen.getByTestId(COLUMN_A).getAttribute(FOCUSED_ATTR)).toBe("true");
});
it("drops the mark once the reader touches the deck", () => {
render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
onOpenTask={() => {}}
/>,
);
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBe("true");
fireEvent.pointerDown(screen.getByTestId(COLUMN_A));
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
});
it("drops the mark when focus lands anywhere in the deck", () => {
render(
<ThreadsBoard threads={[thread({ taskId: "b" })]} focusedTaskId="b" onOpenTask={() => {}} />,
);
fireEvent.focusIn(screen.getByTestId(COLUMN_B));
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
});
it("marks again when a later deep link asks for another column", () => {
const { rerender } = render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
onOpenTask={() => {}}
/>,
);
fireEvent.pointerDown(screen.getByTestId(COLUMN_A));
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
rerender(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="a"
onOpenTask={() => {}}
/>,
);
expect(screen.getByTestId(COLUMN_A).getAttribute(FOCUSED_ATTR)).toBe("true");
});
it("keeps the mark retired while the same deep link stays in the URL", () => {
const { rerender } = render(
<ThreadsBoard threads={[thread({ taskId: "b" })]} focusedTaskId="b" onOpenTask={() => {}} />,
);
fireEvent.pointerDown(screen.getByTestId(COLUMN_B));
rerender(
<ThreadsBoard
threads={[thread({ taskId: "b", queuedPromptCount: 2 })]}
focusedTaskId="b"
onOpenTask={() => {}}
/>,
);
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
});
});