feat(rpc): add validated desktop Pi RPC command palette

This commit is contained in:
2026-08-24 20:16:51 +02:00
parent 475f95ba70
commit 99084cc5a5
25 changed files with 2170 additions and 344 deletions
+154 -21
View File
@@ -4,12 +4,14 @@ 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 { Workspace } from "../workspace/types";
import type { DirectorySession, Workspace } from "../workspace/types";
const summary = (id: string, state = "idle") => ({
runtimeId: id,
worktreePath: "/repo",
@@ -96,41 +98,172 @@ describe("multi-session navigation", () => {
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
expect(onFocusRestored).toHaveBeenCalled();
});
it("traps and restores focus for the saved-session picker", async () => {
it("collapses a tool result until its summary is expanded", async () => {
const user = userEvent.setup();
const runtime = runtimeView(summary("tool-result"));
runtime.messages = [
{ role: "toolResult", toolName: "read", content: "Detailed tool output" },
];
const { container } = 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()}
/>,
);
const details = container.querySelector("details");
expect(details).not.toHaveAttribute("open");
await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open");
});
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={[
{
path: "/s",
id: "s",
cwd: "/repo",
modified: new Date().toISOString(),
messageCount: 0,
},
]}
onOpen={vi.fn()}
onClose={onClose}
/>,
<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("{Tab}");
expect(
screen.getByRole("button", { name: /Untitled session/ }),
).toHaveFocus();
await user.keyboard("{Tab}");
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, and close controls through callbacks", async () => {
const onNew = vi.fn(),
onResume = vi.fn(),