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(); }); });