import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DirectorySidebar } from "./DirectorySidebar";
import { SessionTabs } from "./SessionTabs";
import { SessionPicker } from "./SessionPicker";
import { ConversationWorkspace } from "./ConversationWorkspace";
import { ExtensionDialog } from "./ExtensionDialog";
import {
initialWorkspaceState,
reconcileWorkspace,
runtimeView,
} from "../workspace/reducer";
import type { DirectorySession, Workspace } from "../workspace/types";
const summary = (id: string, state = "idle") => ({
runtimeId: id,
worktreePath: "/repo",
state,
label: id,
attention: id === "b",
queueCount: id === "b" ? 2 : 0,
lastActivity: id,
openedAt: id,
});
const workspace: Workspace = {
bridgeInstanceId: "e",
latestSeq: 0,
directories: [
{
worktreePath: "/repo",
isHome: false,
openCount: 2,
workingCount: 1,
attentionCount: 1,
recoveringCount: 0,
errorCount: 1,
runtimes: [summary("a"), summary("b", "streaming")],
},
],
};
describe("multi-session navigation", () => {
it("renders directory aggregates and accessible keyboard session tabs", async () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const onSelect = vi.fn();
const onNew = vi.fn();
const onRenew = vi.fn();
const { rerender } = render(
<>
>,
);
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
const tabs = screen.getAllByRole("tab");
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
const closeFirst = screen.getByRole("button", { name: "Close a" });
const renewFirst = screen.getByRole("button", { name: "Renew a" });
expect(tabs[0].parentElement?.lastElementChild).toBe(closeFirst);
expect(closeFirst.previousElementSibling).toBe(renewFirst);
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
const newSession = screen.getByRole("button", { name: "New session" });
expect(tablist.previousElementSibling).toHaveAccessibleName(
"Scroll sessions left",
);
expect(tablist.nextElementSibling).toHaveAccessibleName(
"Scroll sessions right",
);
expect(newSession.closest(".session-tab-actions")).not.toBeNull();
await userEvent.click(newSession);
expect(onNew).toHaveBeenCalledOnce();
await userEvent.click(tabs[0]);
await userEvent.keyboard("{ArrowRight}");
expect(onSelect).toHaveBeenCalledWith("b");
rerender(
,
);
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
});
it("scrolls overflowing sessions and reveals the selected tab", async () => {
const user = userEvent.setup();
const runtimes = ["a", "b", "c", "d", "e"].map((id) =>
runtimeView(summary(id)),
);
const props = {
runtimes,
onSelect: vi.fn(),
onClose: vi.fn(),
onRenew: vi.fn(),
onNew: vi.fn(),
onResume: vi.fn(),
};
const { rerender } = render();
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
const scrollBy = vi.fn();
Object.defineProperties(tablist, {
scrollWidth: { configurable: true, value: 800 },
clientWidth: { configurable: true, value: 240 },
scrollLeft: { configurable: true, value: 0, writable: true },
scrollBy: { configurable: true, value: scrollBy },
});
fireEvent.scroll(tablist);
const previous = screen.getByRole("button", {
name: "Scroll sessions left",
});
const next = screen.getByRole("button", { name: "Scroll sessions right" });
expect(previous).toBeDisabled();
expect(next).toBeEnabled();
await user.click(next);
expect(scrollBy).toHaveBeenCalledWith({ behavior: "smooth", left: 180 });
(tablist as HTMLDivElement).scrollLeft = 300;
fireEvent.wheel(tablist, { deltaX: 0, deltaY: -80 });
expect((tablist as HTMLDivElement).scrollLeft).toBe(220);
(tablist as HTMLDivElement).scrollLeft = 560;
fireEvent.scroll(tablist);
expect(previous).toBeEnabled();
expect(next).toBeDisabled();
const finalTab = screen.getByRole("tab", { name: /^e idle$/ });
const scrollIntoView = vi.fn();
Object.defineProperty(finalTab, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
rerender();
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
});
it("announces close progress only on the closing session", () => {
render(
,
);
const closing = screen.getByRole("button", { name: "Closing b" });
expect(closing).toBeDisabled();
expect(closing).toHaveAttribute("aria-busy", "true");
expect(screen.getByRole("button", { name: "Close a" })).toBeEnabled();
});
it("restores focus to the selected adjacent tab after close", () => {
const onFocusRestored = vi.fn();
render(
,
);
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
expect(onFocusRestored).toHaveBeenCalled();
});
it("shows two-line tool results and previews longer collapsed results", async () => {
const user = userEvent.setup();
const runtime = runtimeView(summary("tool-result"));
runtime.messages = [
{ role: "toolResult", toolName: "read", content: "first\nsecond\n" },
];
const props = {
onDraft: vi.fn(),
onSubmit: vi.fn(),
onAbort: vi.fn(),
onScroll: vi.fn(),
onModel: vi.fn().mockResolvedValue(true),
onThinking: vi.fn().mockResolvedValue(true),
onCommand: vi.fn(),
};
const { container, rerender } = render(
,
);
expect(container.querySelector("details")).toBeNull();
expect(container.querySelector(".tool-result-visible pre")).toHaveTextContent(
"first second",
);
const longRuntime = {
...runtime,
messages: [
{
role: "toolResult",
toolName: "read",
content: "first\nsecond\nthird\n",
},
],
};
rerender();
const details = container.querySelector("details");
expect(details).not.toHaveAttribute("open");
expect(container.querySelector(".tool-result-preview")?.textContent).toBe(
"first\nsecond",
);
expect(container.querySelector(".tool-result-full")?.textContent).toBe(
"first\nsecond\nthird\n",
);
expect(
screen.getByText("1 more line hidden · Expand to view"),
).toBeInTheDocument();
await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open");
});
it("uses omitted-reasoning state model metadata when catalog lacks current model", async () => {
const runtime = runtimeView(summary("thinking"));
runtime.state = {
model: {
provider: "provider",
id: "current",
thinkingLevelMap: { high: null, xhigh: "xhigh" },
},
};
render(
,
);
const thinking = screen.getByRole("combobox", { name: "Thinking" });
expect(
within(thinking)
.getAllByRole("option")
.map((option) => option.getAttribute("value")),
).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
});
it("packs session status between model controls and the composer", () => {
const runtime = runtimeView(summary("status", "streaming"));
runtime.summary.queueCount = 2;
runtime.stats = {
tokens: { input: 10_000, output: 2_450, cacheRead: 8_000, total: 12_450 },
contextUsage: { tokens: 32_000, contextWindow: 200_000, percent: 16 },
};
const { container } = render(
,
);
const modelControls = screen.getByRole("region", {
name: "Model and thinking controls",
});
const status = screen.getByRole("status", { name: "Session status" });
const composer = container.querySelector(".composer");
for (const text of [
"Working",
"Thinking and preparing a response",
"Bridge",
"ready",
"In",
"10k",
"Out",
"2.5k",
"Cache read",
"8k",
"Context",
"32k / 200k · 16%",
"Queue",
"2",
"LSP active",
])
expect(status).toHaveTextContent(text);
expect(status.textContent).not.toMatch(/[\u001B\u0007\uE0B0\uFFFD]/u);
expect(modelControls.nextElementSibling).toBe(status);
expect(status.nextElementSibling).toBe(composer);
});
it("keeps working activity at the transcript tail and follows it from the bottom", () => {
const idle = runtimeView(summary("activity"));
idle.messages = [{ role: "assistant", content: "Partial response" }];
const props = {
onDraft: vi.fn(),
onSubmit: vi.fn(),
onAbort: vi.fn(),
onScroll: vi.fn(),
onModel: vi.fn().mockResolvedValue(true),
onThinking: vi.fn().mockResolvedValue(true),
onCommand: vi.fn(),
};
const { rerender } = render(
,
);
const transcript = screen.getByRole("region", { name: "Conversation" });
Object.defineProperty(transcript, "scrollHeight", {
configurable: true,
value: 600,
});
transcript.scrollTop = 125;
rerender(
,
);
const activity = screen.getByText("Pi is working").closest(".agent-working");
expect(transcript.lastElementChild).toBe(activity);
expect(transcript.scrollTop).toBe(600);
});
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
const user = userEvent.setup();
const onScroll = vi.fn();
const atBottom = runtimeView(summary("scroll"));
const props = {
onDraft: vi.fn(),
onSubmit: vi.fn(),
onAbort: vi.fn(),
onScroll,
onModel: vi.fn().mockResolvedValue(true),
onThinking: vi.fn().mockResolvedValue(true),
onCommand: vi.fn(),
};
const { rerender } = render(
,
);
expect(
screen.queryByRole("button", { name: "Scroll to latest message" }),
).toBeNull();
const scrolledUp = {
...atBottom,
scroll: { atBottom: false, scrollTop: 125 },
};
rerender();
const transcript = screen.getByRole("region", { name: "Conversation" });
Object.defineProperty(transcript, "scrollHeight", {
configurable: true,
value: 600,
});
transcript.scrollTop = 125;
await user.click(
screen.getByRole("button", { name: "Scroll to latest message" }),
);
expect(transcript.scrollTop).toBe(600);
expect(onScroll).toHaveBeenCalledWith(true, 600);
expect(transcript).toHaveFocus();
});
it("shows an aborting control and labels an accepted follow-up", () => {
const runtime = runtimeView(summary("working", "streaming"));
runtime.progress = {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
};
runtime.pendingSubmissions = [
{
id: 1,
text: "Continue with the plan",
userMessageCount: 0,
phase: "sent",
delivery: "follow_up",
},
];
render(
,
);
expect(screen.getByRole("button", { name: "Aborting…" })).toBeDisabled();
expect(
screen.getByText("Follow-up queued · waiting for Pi"),
).toBeInTheDocument();
});
it("searches saved sessions and displays useful metadata", async () => {
const user = userEvent.setup();
const onOpen = vi.fn();
const sessions: DirectorySession[] = [
{
path: "/planning",
id: "plan-id",
cwd: "/repo",
name: "Planning",
firstMessage: "Sketch the release plan",
modified: "2026-08-15T10:00:00.000Z",
messageCount: 1,
runtimeId: "already-open",
},
{
path: "/incident",
id: "incident-42",
cwd: "/repo",
firstMessage: "Investigate search crash",
modified: "2026-08-14T10:00:00.000Z",
messageCount: 2,
},
];
render(
,
);
const search = screen.getByRole("searchbox", {
name: "Search saved sessions",
});
expect(screen.getByRole("status")).toHaveTextContent("2 saved sessions");
expect(screen.getByText("Sketch the release plan")).toBeInTheDocument();
expect(screen.getByText("1 message")).toBeInTheDocument();
expect(screen.getByText("Open")).toBeInTheDocument();
await user.type(search, "release");
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
expect(screen.getByRole("button", { name: /Planning/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
await user.clear(search);
await user.type(search, "INCIDENT-42");
await user.click(screen.getByRole("button", { name: /Investigate/ }));
expect(onOpen).toHaveBeenCalledWith(sessions[1]);
await user.clear(search);
await user.type(search, "missing");
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
});
it("opens a native modal and handles cancellation", () => {
const onClose = vi.fn();
render();
const dialog = screen.getByRole("dialog", { name: "Saved sessions" });
expect(dialog).toHaveAttribute("open");
expect(
screen.getByRole("searchbox", { name: "Search saved sessions" }),
).toHaveFocus();
fireEvent(dialog, new Event("cancel", { cancelable: true }));
expect(onClose).toHaveBeenCalled();
});
it("labels permission requests with the action and exact details", async () => {
const user = userEvent.setup();
const onRespond = vi.fn();
render(
,
);
expect(screen.getByText("Pi agent request")).toBeInTheDocument();
expect(screen.getByText("Requested action")).toBeInTheDocument();
expect(screen.getByText("Run a shell command")).toBeInTheDocument();
expect(screen.getByText("Request details")).toBeInTheDocument();
expect(screen.getByText(/rm -rf .\/build/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Allow request" }));
expect(onRespond).toHaveBeenCalledWith({ confirmed: true });
});
it("uses Pi editor prefill content", () => {
render(
,
);
expect(screen.getByLabelText("Extension value")).toHaveValue(
"Existing request",
);
});
it("routes new, saved-open, renew, and close controls through callbacks", async () => {
const onNew = vi.fn(),
onResume = vi.fn(),
onRenew = vi.fn(),
onClose = vi.fn();
render(
,
);
await userEvent.click(screen.getByRole("button", { name: "New session" }));
await userEvent.click(screen.getByRole("button", { name: "Load" }));
await userEvent.click(screen.getByLabelText("Renew a"));
await userEvent.click(screen.getByLabelText("Close a"));
expect(onNew).toHaveBeenCalled();
expect(onResume).toHaveBeenCalled();
expect(onRenew).toHaveBeenCalledWith(
expect.objectContaining({
summary: expect.objectContaining({ runtimeId: "a" }),
}),
);
expect(onClose).toHaveBeenCalled();
});
});