166b065ad4
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state. Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { render, screen } 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(
|
|
<>
|
|
<DirectorySidebar
|
|
state={state}
|
|
onSelect={vi.fn()}
|
|
onAdd={vi.fn()}
|
|
onForget={vi.fn()}
|
|
/>
|
|
<SessionTabs
|
|
runtimes={[
|
|
runtimeView(summary("a")),
|
|
runtimeView(summary("b", "streaming")),
|
|
]}
|
|
selectedId="a"
|
|
onSelect={onSelect}
|
|
onClose={vi.fn()}
|
|
onRenew={onRenew}
|
|
onNew={onNew}
|
|
onResume={vi.fn()}
|
|
/>
|
|
</>,
|
|
);
|
|
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.nextElementSibling).toBe(newSession);
|
|
await userEvent.click(newSession);
|
|
expect(onNew).toHaveBeenCalledOnce();
|
|
await userEvent.click(tabs[0]);
|
|
await userEvent.keyboard("{ArrowRight}");
|
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
rerender(
|
|
<SessionTabs
|
|
runtimes={[runtimeView(summary("b", "streaming"))]}
|
|
selectedId="b"
|
|
onSelect={onSelect}
|
|
onClose={vi.fn()}
|
|
onRenew={vi.fn()}
|
|
onNew={vi.fn()}
|
|
onResume={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
|
});
|
|
it("announces close progress only on the closing session", () => {
|
|
render(
|
|
<SessionTabs
|
|
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
|
selectedId="a"
|
|
onSelect={vi.fn()}
|
|
onClose={vi.fn()}
|
|
onRenew={vi.fn()}
|
|
onNew={vi.fn()}
|
|
onResume={vi.fn()}
|
|
closingRuntimeIds={new Set(["b"])}
|
|
/>,
|
|
);
|
|
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(
|
|
<SessionTabs
|
|
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
|
selectedId="b"
|
|
onSelect={vi.fn()}
|
|
onClose={vi.fn()}
|
|
onRenew={vi.fn()}
|
|
onNew={vi.fn()}
|
|
onResume={vi.fn()}
|
|
restoreFocus
|
|
onFocusRestored={onFocusRestored}
|
|
/>,
|
|
);
|
|
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(
|
|
<ConversationWorkspace runtime={runtime} {...props} />,
|
|
);
|
|
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(<ConversationWorkspace runtime={longRuntime} {...props} />);
|
|
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("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(
|
|
<ConversationWorkspace runtime={atBottom} {...props} />,
|
|
);
|
|
expect(
|
|
screen.queryByRole("button", { name: "Scroll to latest message" }),
|
|
).toBeNull();
|
|
|
|
const scrolledUp = {
|
|
...atBottom,
|
|
scroll: { atBottom: false, scrollTop: 125 },
|
|
};
|
|
rerender(<ConversationWorkspace runtime={scrolledUp} {...props} />);
|
|
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(
|
|
<ConversationWorkspace
|
|
runtime={runtime}
|
|
onDraft={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
onAbort={vi.fn()}
|
|
onScroll={vi.fn()}
|
|
onModel={vi.fn()}
|
|
onThinking={vi.fn()}
|
|
onCommand={vi.fn()}
|
|
isAborting
|
|
/>,
|
|
);
|
|
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(
|
|
<SessionPicker sessions={sessions} onOpen={onOpen} onClose={vi.fn()} />,
|
|
);
|
|
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("focuses search, traps focus, and restores the opener", async () => {
|
|
const user = userEvent.setup();
|
|
const opener = document.createElement("button");
|
|
document.body.append(opener);
|
|
opener.focus();
|
|
const onClose = vi.fn();
|
|
const { unmount } = render(
|
|
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
|
|
);
|
|
const search = screen.getByRole("searchbox", {
|
|
name: "Search saved sessions",
|
|
});
|
|
const close = screen.getByRole("button", { name: "Close" });
|
|
expect(search).toHaveFocus();
|
|
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
|
expect(close).toHaveFocus();
|
|
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
|
expect(search).toHaveFocus();
|
|
await user.keyboard("{Escape}");
|
|
expect(onClose).toHaveBeenCalled();
|
|
unmount();
|
|
expect(opener).toHaveFocus();
|
|
document.body.removeChild(opener);
|
|
});
|
|
it("labels permission requests with the action and exact details", async () => {
|
|
const user = userEvent.setup();
|
|
const onRespond = vi.fn();
|
|
render(
|
|
<ExtensionDialog
|
|
extension={{
|
|
method: "confirm",
|
|
title: "Run a shell command",
|
|
message: "rm -rf ./build\nThis removes the generated build directory.",
|
|
}}
|
|
onRespond={onRespond}
|
|
/>,
|
|
);
|
|
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(
|
|
<ExtensionDialog
|
|
extension={{
|
|
method: "editor",
|
|
title: "Edit request",
|
|
prefill: "Existing request",
|
|
}}
|
|
onRespond={vi.fn()}
|
|
/>,
|
|
);
|
|
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(
|
|
<SessionTabs
|
|
runtimes={[runtimeView(summary("a"))]}
|
|
selectedId="a"
|
|
onSelect={vi.fn()}
|
|
onClose={onClose}
|
|
onRenew={onRenew}
|
|
onNew={onNew}
|
|
onResume={onResume}
|
|
/>,
|
|
);
|
|
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();
|
|
});
|
|
});
|