Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 0bc8c00ea6 test(server): identify the phase of Windows setup timeouts 2026-11-06 21:05:17 +00:00
135 changed files with 432 additions and 5563 deletions
-7
View File
@@ -570,13 +570,6 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Replace sidebar content
The sidebar view slot is global across hosts, while header and footer contributions retain their
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
its layout. This keeps plugin failures from removing navigation or recovery controls. The
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
@@ -1,11 +1,5 @@
import { expect, test, type Page } from "../support/fixtures";
import {
pasteRichTextIntoComposer,
fillComposerDraft,
copyLatestTurnIntoComposer,
selectComposerDraftRange,
undoComposerEdit,
redoComposerEdit,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -34,7 +28,6 @@ import {
openMobileAgentSidebarWithKeyboard,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
import {
expectTimelinePromptPositionPreserved,
@@ -62,9 +55,7 @@ test("clicking chat text or background focuses the feed for keyboard paging", as
await expect
.poll(async () => (await readScrollMetrics(page)).offsetY)
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
await page.screenshot({
path: testInfo.outputPath("chat-keyboard-paging.png"),
});
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
await page.keyboard.press("PageDown");
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
@@ -133,15 +124,11 @@ test("focusing the feed preserves assistant text selection and code copy control
});
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const copiedTable =
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-",
title: "Composer focus",
featureValues: { mockAssistantResponse: copiedTable },
});
const connection = await installDaemonWebSocketGate(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
@@ -149,62 +136,8 @@ test("submitting a message leaves the composer ready for the next message", asyn
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
await selectComposerDraftRange(page, 13, 25);
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/issues/39150">this FR</a>',
text: "this FR",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await undoComposerEdit(page);
await expectComposerDraft(page, "Investigate: replace this. Thanks");
await redoComposerEdit(page);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await typeIntoFocusedComposer(page, " next");
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible();
await connection.drop();
await connection.waitForBlockedConnection();
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/offline">offline link</a>',
text: "offline link",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
text: "Resource\tStatus\nIssue\tclosed",
});
await expectComposerDraft(
page,
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/">Issue</a><table></table>',
text: "Issue",
});
await expectComposerDraft(page, "[Issue](https://example.com/)");
await copyLatestTurnIntoComposer(page);
await expectComposerDraft(page, copiedTable);
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
} finally {
await agent.cleanup();
}
@@ -235,11 +168,7 @@ async function waitForAnimationFrames(page: Page, count: number): Promise<void>
test.describe("touch screen wide enough for the desktop layout", () => {
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
// composer there raises the on-screen keyboard over the conversation.
test.use({
viewport: { width: 1024, height: 768 },
isMobile: true,
hasTouch: true,
});
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -263,9 +192,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
test("returning to an inactive inline composer does not restore focus on touch", async ({
page,
}) => {
const workspace = await seedWorkspace({
repoPrefix: "composer-touch-retained-",
});
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
@@ -1,18 +0,0 @@
import { test } from "../support/fixtures";
import { withSidebarViews } from "../support/helpers/sidebar-views";
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
page,
}) => {
test.setTimeout(150_000);
await withSidebarViews(page, async (views) => {
await views.expectDefault();
await views.chooseWorkspaces();
await views.exerciseExamples();
await views.exerciseTerminals();
await views.restoreDefault();
await views.recoverFromFailure();
await views.chooseWorkspaces();
await views.disableChosenView();
});
});
@@ -112,7 +112,6 @@ test.describe("Workspace archive undo", () => {
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
await expectDirectoryPresence(worktree.workspaceDirectory, true);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
});
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
+10 -77
View File
@@ -31,9 +31,7 @@ export async function expectComposerVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(composerInput(page)).toBeVisible({
timeout: options?.timeout ?? 15_000,
});
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
}
export async function expectComposerDisabled(page: Page): Promise<void> {
@@ -158,9 +156,7 @@ export async function attachImageFromMenu(
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
export async function attachFileFromMenu(
@@ -222,24 +218,18 @@ export async function expectGithubAttachmentPill(
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
@@ -293,10 +283,7 @@ export async function startRunningMockAgent(
model: opts.model,
featureValues: opts.featureValues,
});
const agentUrl = `${buildHostWorkspaceRoute(
serverId,
workspace.id,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
@@ -360,11 +347,7 @@ export async function pasteComposerTestImage(page: Page): Promise<boolean> {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", {
clipboardData,
bubbles: true,
cancelable: true,
});
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
input.dispatchEvent(event);
return event.defaultPrevented;
}, COMPOSER_TEST_IMAGE_BASE64);
@@ -522,10 +505,7 @@ export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
const frames = await page.evaluate(() => {
Reflect.get(window, "__stopComposerGeometry")?.();
return Reflect.get(window, "__composerGeometryFrames") as {
input: number;
root: number;
}[];
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
});
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
@@ -536,10 +516,7 @@ export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", {
name: activeChatTitle,
exact: true,
});
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
@@ -579,10 +556,7 @@ export async function roundTripComposerTabBeforePaint(
});
}
});
observer.observe(tab, {
attributes: true,
attributeFilter: ["aria-selected"],
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
shortcut("BracketLeft");
});
});
@@ -592,44 +566,3 @@ export async function roundTripComposerTabBeforePaint(
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
/** Paste both clipboard representations through the browser's normal paste command. */
export async function pasteRichTextIntoComposer({
page,
html,
text,
}: {
page: Page;
html: string;
text: string;
}): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ html: richHtml, text: plainText }) => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([richHtml], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
}),
]);
},
{ html, text },
);
await composerInput(page).press("ControlOrMeta+v");
}
export async function undoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+z");
}
export async function redoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+Shift+z");
}
/** Reuse the actual response Copy action and the browser's native paste. */
export async function copyLatestTurnIntoComposer(page: Page): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.getByRole("button", { name: "Copy turn", exact: true }).last().click();
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
await composerInput(page).fill("");
await composerInput(page).press("ControlOrMeta+v");
}
@@ -17,8 +17,6 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "createTerminal"
| "killTerminal"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
@@ -36,7 +34,6 @@ type NewWorkspaceDaemonClient = Pick<
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
| "setWorkspaceLabel"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -1,191 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { gotoWorkspace } from "./launcher";
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
import { visibleTestId } from "./plugin-sidebar-items";
import { getServerId } from "./server-id";
export async function withSidebarViews(
page: Page,
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
) {
const example = await copyPluginExample("sidebar-views");
const entry = path.join(example.directory, "index.client.tsx");
const original = await readFile(entry, "utf8");
await writeFile(
entry,
original.replace(
"const cleanups = [",
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
),
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-views-",
title: "Sidebar view workspace",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(sidebarViews(page, workspace.workspaceId, client));
} finally {
await client.removePlugin("sidebar-views");
await client.close();
await workspace.cleanup();
await example.cleanup();
}
}
function sidebarViews(
page: Page,
workspaceId: string,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
) {
const builtinRow = () =>
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
async function choose(title: string) {
await openSidebarNavSettings(page);
await page.getByRole("button", { name: /^View:/ }).click();
await page.getByRole("menuitem", { name: title, exact: true }).click();
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
await leaveSettings(page);
}
async function expectDefault() {
await expect(builtinRow()).toBeVisible();
}
async function expectPlugin() {
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
exact: true,
}),
).toBeVisible();
await expect(builtinRow()).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
}
return {
expectDefault,
async chooseWorkspaces() {
await choose("Example workspaces");
await expectPlugin();
},
async exerciseExamples() {
await choose("Example agents");
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
).toBeVisible();
await choose("Example nested agents");
await expectPlugin();
await choose("Example label folders");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toContain("Inbox");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Later"]);
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Unlabelled", color: "red" },
assigned: true,
});
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Later", color: "red" },
assigned: false,
});
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Unlabelled"]);
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Inbox"]);
},
async exerciseTerminals() {
await choose("Example terminals");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
.click();
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
const workspace = (await client.fetchWorkspaces()).entries.find(
(item) => item.id === workspaceId,
)!;
const created = await client.createTerminal(
workspace.workspaceDirectory,
"Sidebar terminal",
undefined,
{ workspaceId },
);
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
try {
await expect(
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
).toBeVisible();
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
await expect
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
.not.toContain(created.terminal!.id);
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
} finally {
await client.killTerminal(created.terminal.id);
}
},
async restoreDefault() {
await choose("Default view");
await expectDefault();
},
async recoverFromFailure() {
await choose("Broken example");
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Use default view", exact: true }).click();
await expectDefault();
},
async disableChosenView() {
await client.disablePlugin("sidebar-views");
await expectDefault();
await openSidebarNavSettings(page);
await expect(
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
exact: true,
}),
).toBeVisible();
await leaveSettings(page);
},
};
}
+50 -55
View File
@@ -1,4 +1,3 @@
import { SidebarViewSlot } from "@/plugins/sidebar-view";
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
@@ -643,33 +642,31 @@ function MobileSidebar({
)}
<SidebarSeparator />
<SidebarViewSlot dismiss={handleWorkspacePress}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarFooter
handleOpenProject={handleOpenProject}
@@ -841,33 +838,31 @@ function DesktopSidebar({
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
</View>
<SidebarViewSlot dismiss={closeSidebar}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarCalloutSlot />
+6 -11
View File
@@ -88,7 +88,6 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { pasteLinkedClipboardText } from "@/composer/pasted-text";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
EditingTextInput,
@@ -514,7 +513,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
return native instanceof HTMLElement ? native : null;
}
interface PasteEffectArgs {
interface PasteImagesEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
@@ -523,7 +522,7 @@ interface PasteEffectArgs {
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
}
function usePasteEffect(args: PasteEffectArgs): void {
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const {
textarea,
isConnected,
@@ -534,20 +533,16 @@ function usePasteEffect(args: PasteEffectArgs): void {
} = args;
useEffect(() => {
if (!isWeb) return;
if (!isWeb || !onAddImages) return;
if (!textarea) return;
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) {
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
return;
}
if (!isConnected || !onAddImages) return;
if (imageFiles.length === 0) return;
event.preventDefault();
@@ -1816,7 +1811,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteEffect({
usePasteImagesEffect({
textarea: webTextArea,
isConnected,
disabled,
@@ -1,144 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createMarkdownClipboardContent } from "@/utils/rich-clipboard";
import { pasteLinkedClipboardText } from "./pasted-text.web";
const inputs: HTMLTextAreaElement[] = [];
function editor(): HTMLTextAreaElement {
const input = document.createElement("textarea");
document.body.append(input);
inputs.push(input);
return input;
}
afterEach(() => {
for (const input of inputs.splice(0)) input.remove();
});
function clipboard(html: string, text: string): DataTransfer {
const data = new DataTransfer();
data.setData("text/html", html);
data.setData("text/plain", text);
return data;
}
describe("pasted prompt links", () => {
it.each([
"https://example.com/1",
"https://example.com/日本語",
"<https://example.com/a b>",
"https://例え.テスト/issue",
])("keeps complete Markdown from Copy with destination %s", (destination) => {
const source = `| Resource | Status |\n| --- | --- |\n| [issue](${destination}) | ~~closed~~ |`;
const content = createMarkdownClipboardContent(source);
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false);
expect(input.value).toBe("");
});
it("converts external links when plain text names the resource but omits a destination", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<a href="https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E">日本語</a> and <a href="https://example.com/missing">another</a>',
"[日本語](https://example.com/日本語) and another",
),
),
).toBe(true);
expect(input.value).toBe(
"[日本語](https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E) and [another](https://example.com/missing)",
);
});
it("preserves a labeled resource URL as Markdown", () => {
const data = clipboard(
'Hey can you take a look at <a href="https://example.com/issues/39150">this FR</a> and...',
"Hey can you take a look at this FR and...",
);
const input = editor();
expect(pasteLinkedClipboardText(input, data)).toBe(true);
expect(input.value).toBe(
"Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
);
});
it("preserves external linked table rows and columns", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
"Resource\tStatus\nIssue\tclosed",
),
),
).toBe(true);
expect(input.value).toBe(
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
);
});
it("keeps table cell separators and line breaks inside their cells", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<table><tr><th>Resource</th><th>Status</th></tr><tr><td><a href="https://example.com/issue">Issue | tracker</a></td><td>closed<br>today</td></tr></table>',
"Resource\tStatus\nIssue | tracker\tclosed today",
),
),
).toBe(true);
expect(input.value).toBe(
"| Resource | Status |\n| --- | --- |\n| [Issue \\| tracker](https://example.com/issue) | closed<br>today |",
);
});
it("retains headerless tables as HTML without inventing a heading", () => {
const html =
'<table><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr><tr><td>Other</td><td>open</td></tr></tbody></table>';
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard(html, "Issue\tclosed\nOther\topen"))).toBe(
true,
);
expect(input.value).toBe(html);
});
it.each([
["<table></table>", ""],
["<table><caption>Context</caption></table>", "\n\nContext"],
])("keeps surrounding links when a table has no rows: %s", (table, caption) => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(`<a href="https://example.com/">Issue</a>${table}`, "Issue"),
),
).toBe(true);
expect(input.value).toBe(`[Issue](https://example.com/)${caption}`);
});
it("leaves plain text and rich text without links to native paste", () => {
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe(
false,
);
expect(pasteLinkedClipboardText(input, clipboard("<b>hello</b>", "hello"))).toBe(false);
expect(pasteLinkedClipboardText(input, null)).toBe(false);
expect(input.value).toBe("");
});
it("keeps paragraphs and multiple links", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<p>See <a href="https://example.com/a">one</a></p><p>and <a href="https://example.com/b">two</a></p>',
"See one\n\nand two",
),
),
).toBe(true);
expect(input.value).toBe(
"See [one](https://example.com/a)\n\nand [two](https://example.com/b)",
);
});
});
-7
View File
@@ -1,7 +0,0 @@
/** Native inputs retain their platform clipboard handling. */
export function pasteLinkedClipboardText(
_input: HTMLElement,
_clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
return false;
}
@@ -1,59 +0,0 @@
import TurndownService from "turndown";
import { tables } from "turndown-plugin-gfm";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
const markdown = new TurndownService({
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
// Heading tables become Markdown; headerless tables retain their HTML structure.
markdown.use(tables);
markdown.addRule("rowlessTable", {
filter: (node) => node.nodeName === "TABLE" && (node as HTMLTableElement).rows.length === 0,
replacement: (content) => (content ? `\n\n${content}\n\n` : ""),
});
markdown.addRule("tableCell", {
filter: ["th", "td"],
replacement: (content, node) => {
const prefix = node.previousElementSibling ? " " : "| ";
const cell = content.trim().replace(/\|/g, "\\|").replace(/ *\n/g, "<br>");
return `${prefix}${cell} |`;
},
});
const copiedMarkdown = createAssistantMarkdownParser();
function linkDestinations(fragment: Document): string[] {
return Array.from(fragment.querySelectorAll("a[href]"), (link) => link.getAttribute("href")!);
}
function containsDestinations(plainText: string, destinations: string[]): boolean {
if (destinations.every((url) => plainText.includes(url))) return true;
// Copy normalizes Markdown URLs in HTML (percent encoding, internationalized
// domains). Compare parsed destinations using that same parser, not URL spelling.
const rendered = new DOMParser().parseFromString(copiedMarkdown.render(plainText), "text/html");
const links = new Set(linkDestinations(rendered));
return destinations.every((url) => plainText.includes(url) || links.has(url));
}
/** Paste linked HTML as Markdown through the browser's undoable editing command. */
export function pasteLinkedClipboardText(
input: HTMLElement,
clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
const html = clipboard?.getData("text/html");
if (!html) return false;
// Parse in a detached document. Clipboard markup is never mounted in the app.
const fragment = new DOMParser().parseFromString(html, "text/html");
const destinations = linkDestinations(fragment);
const plainText = clipboard!.getData("text/plain");
// Copy actions already put complete Markdown on the clipboard. Keep that
// representation, including tables and formatting, whenever it carries the URLs.
if (containsDestinations(plainText, destinations)) return false;
const text = markdown.turndown(fragment.body);
input.focus();
// Direct value assignment drops the textarea's undo history. This also emits
// the normal input event so the editor publishes its new draft as for typing.
return input.ownerDocument.execCommand("insertText", false, text);
}
+4 -4
View File
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
} {
const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [],
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
function on<K extends RouterMessageType>(
type: K,
+2 -5
View File
@@ -41,11 +41,8 @@ export function useReplicaQuery<
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input), queryClient);
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input));
}
export function useFetchQuery<
-14
View File
@@ -1179,14 +1179,6 @@ export const ar: TranslationResources = {
},
},
sidebar: {
view: {
failed: "تعذر تحميل عرض الشريط الجانبي.",
useDefault: "استخدام العرض الافتراضي",
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
agentUnavailable: "لم يعد الوكيل متاحًا",
terminalUnavailable: "لم تعد الطرفية متاحة",
projectUnavailable: "لم يعد المشروع متاحًا",
},
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
@@ -2615,12 +2607,6 @@ export const ar: TranslationResources = {
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
view: {
title: "عرض الشريط الجانبي",
label: "العرض",
default: "العرض الافتراضي",
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
},
header: {
title: "الرأس",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
-14
View File
@@ -1187,14 +1187,6 @@ export const en = {
},
},
sidebar: {
view: {
failed: "Sidebar view failed to load.",
useDefault: "Use default view",
readFailed: "Could not load your sidebar view preference.",
agentUnavailable: "Agent is no longer available",
terminalUnavailable: "Terminal is no longer available",
projectUnavailable: "Project is no longer available",
},
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
@@ -2706,12 +2698,6 @@ export const en = {
description: "Show an outline for jumping between prompts",
},
sidebar: {
view: {
title: "Sidebar view",
label: "View",
default: "Default view",
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
},
header: {
title: "Header",
description: "Choose which items appear at the top of the sidebar and in what order",
-15
View File
@@ -1215,14 +1215,6 @@ export const es: TranslationResources = {
},
},
sidebar: {
view: {
failed: "No se pudo cargar la vista de la barra lateral.",
useDefault: "Usar vista predeterminada",
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
agentUnavailable: "El agente ya no está disponible",
terminalUnavailable: "La terminal ya no está disponible",
projectUnavailable: "El proyecto ya no está disponible",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Necesita datos",
@@ -2635,13 +2627,6 @@ export const es: TranslationResources = {
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
view: {
title: "Vista de la barra lateral",
label: "Vista",
default: "Vista predeterminada",
unavailable:
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
},
header: {
title: "Encabezado",
description:
-15
View File
@@ -1208,14 +1208,6 @@ export const fr: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Impossible de charger la vue de la barre latérale.",
useDefault: "Utiliser la vue par défaut",
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
agentUnavailable: "Cet agent n’est plus disponible",
terminalUnavailable: "Ce terminal n’est plus disponible",
projectUnavailable: "Ce projet n’est plus disponible",
},
statusGroupAccessibility: "Groupe {{label}}",
statusBucket: {
needsInput: "Attend une réponse",
@@ -2636,13 +2628,6 @@ export const fr: TranslationResources = {
description: "Afficher un plan pour naviguer entre les prompts",
},
sidebar: {
view: {
title: "Vue de la barre latérale",
label: "Vue",
default: "Vue par défaut",
unavailable:
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
},
header: {
title: "En-tête",
description:
-15
View File
@@ -1192,14 +1192,6 @@ export const ja: TranslationResources = {
},
},
sidebar: {
view: {
failed: "サイドバーのビューを読み込めませんでした。",
useDefault: "デフォルトのビューを使用",
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
agentUnavailable: "このエージェントは利用できなくなりました",
terminalUnavailable: "このターミナルは利用できなくなりました",
projectUnavailable: "このプロジェクトは利用できなくなりました",
},
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
@@ -2602,13 +2594,6 @@ export const ja: TranslationResources = {
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
view: {
title: "サイドバーのビュー",
label: "ビュー",
default: "デフォルトのビュー",
unavailable:
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
},
header: {
title: "ヘッダー",
description: "サイドバー上部に表示する項目とその順序を選択します",
-15
View File
@@ -1186,14 +1186,6 @@ export const ko: TranslationResources = {
},
},
sidebar: {
view: {
failed: "사이드바 보기를 불러오지 못했습니다.",
useDefault: "기본 보기 사용",
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
},
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
@@ -2593,13 +2585,6 @@ export const ko: TranslationResources = {
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
view: {
title: "사이드바 보기",
label: "보기",
default: "기본 보기",
unavailable:
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
},
header: {
title: "헤더",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
-15
View File
@@ -1205,14 +1205,6 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Não foi possível carregar a visualização da barra lateral.",
useDefault: "Usar visualização padrão",
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
agentUnavailable: "O agente não está mais disponível",
terminalUnavailable: "O terminal não está mais disponível",
projectUnavailable: "O projeto não está mais disponível",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Precisa de resposta",
@@ -2617,13 +2609,6 @@ export const ptBR: TranslationResources = {
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
view: {
title: "Visualização da barra lateral",
label: "Visualização",
default: "Visualização padrão",
unavailable:
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
},
header: {
title: "Cabeçalho",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
-14
View File
@@ -1196,14 +1196,6 @@ export const ru: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Не удалось загрузить вид боковой панели.",
useDefault: "Использовать стандартный вид",
readFailed: "Не удалось загрузить настройку вида боковой панели.",
agentUnavailable: "Агент больше недоступен",
terminalUnavailable: "Терминал больше недоступен",
projectUnavailable: "Проект больше недоступен",
},
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
@@ -2623,12 +2615,6 @@ export const ru: TranslationResources = {
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
view: {
title: "Вид боковой панели",
label: "Вид",
default: "Стандартный вид",
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
},
header: {
title: "Верх",
description:
-14
View File
@@ -1171,14 +1171,6 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
view: {
failed: "无法加载侧边栏视图。",
useDefault: "使用默认视图",
readFailed: "无法加载侧边栏视图偏好设置。",
agentUnavailable: "此智能体已不可用",
terminalUnavailable: "此终端已不可用",
projectUnavailable: "此项目已不可用",
},
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
@@ -2552,12 +2544,6 @@ export const zhCN: TranslationResources = {
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
view: {
title: "侧边栏视图",
label: "视图",
default: "默认视图",
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
},
header: {
title: "顶部",
description: "选择侧边栏顶部显示的项目及其顺序",
+1 -2
View File
@@ -1,4 +1,3 @@
import { getPluginClientApi } from "./client-state/api";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -33,7 +32,7 @@ export function createPluginCapabilities(
navigation.openSurface(plugin.id, screenId, params);
}
return {
paseo: getPluginClientApi(plugin).paseo,
paseo: plugin.paseo,
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
openSettings(screenId) {
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
@@ -23,7 +23,6 @@ function installation(): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -1,4 +1,3 @@
import { getPluginClientApi } from "./client-state/api";
import appPackage from "../../package.json";
import { afterEach, expect, it } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
@@ -140,7 +139,7 @@ function harness() {
},
installation.lifetime.signal,
),
paseo: getPluginClientApi(installation).paseo,
paseo: installation.paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected runtime RPC");
+3 -2
View File
@@ -1,5 +1,6 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getPluginClientApi } from "./client-state/api";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
@@ -26,7 +27,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: getPluginClientApi(installation).hosts,
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -1,68 +0,0 @@
import { createPluginReferenceApi } from "@getpaseo/plugin/client/host";
import { router } from "expo-router";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { createPluginHosts } from "../hosts";
import type { InstalledPlugin } from "../types";
import { createPluginClientStateSource } from "./source";
interface InstallationApi {
paseo: ReturnType<typeof createPluginReferenceApi>;
hosts: ReturnType<typeof createPluginHosts>;
}
// Registry presentation clones share the installation lifetime and its client pool.
const apis = new WeakMap<AbortSignal, InstallationApi>();
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
const cached = apis.get(plugin.lifetime.signal);
if (cached) return cached;
const state = createPluginClientStateSource();
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
const paseo = createPluginReferenceApi({
paseo: plugin.paseo,
resolveHost: hosts.getPaseoClient,
readProject: state.getProject,
navigation: {
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
},
openAgent(ref) {
const agent = state.getAgent(ref);
if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
},
openTerminal(ref) {
const terminal = state.getTerminal(ref);
if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
},
openProject(ref) {
const project = state.getProject(ref);
if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable"));
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
},
},
});
const api = { paseo, hosts };
apis.set(plugin.lifetime.signal, api);
return api;
}
@@ -1,72 +0,0 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { PluginConnectedHostsSource } from "./source";
interface PluginHostsRuntime {
getHosts(): readonly { serverId: string }[];
getSnapshot(serverId: string): { connectionStatus: string } | null | undefined;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
acquireDirectoryDemand(serverId: string): () => void;
}
export function createConnectedPluginHosts(
getRuntime: () => PluginHostsRuntime,
): PluginConnectedHostsSource {
let snapshot: readonly string[] = [];
const listeners = new Set<() => void>();
let release: (() => void) | null = null;
const source: PluginConnectedHostsSource = {
getSnapshot() {
const runtime = getRuntime();
const next = runtime
.getHosts()
.filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online")
.map((host) => host.serverId);
if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index]))
snapshot = Object.freeze(next);
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
if (!release) {
const runtime = getRuntime();
const demands = new Map<string, () => void>();
const reconcile = () => {
const ids = new Set(source.getSnapshot());
for (const [id, releaseDemand] of demands) {
if (!ids.has(id)) {
demands.delete(id);
releaseDemand();
}
}
for (const id of ids) {
if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id));
}
};
const notify = () => {
reconcile();
for (const subscriber of listeners) subscriber();
};
const releaseRuntime = runtime.subscribeAll(notify);
const releaseHosts = runtime.subscribeHostList(notify);
reconcile();
release = () => {
releaseRuntime();
releaseHosts();
for (const releaseDemand of demands.values()) releaseDemand();
demands.clear();
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && release) {
release();
release = null;
}
};
},
};
return source;
}
export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore);
@@ -1,53 +0,0 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
export const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
export const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
@@ -1,3 +0,0 @@
import { createEntityRefs } from "@getpaseo/plugin/client/host";
export const entityRefs = createEntityRefs();
@@ -1,20 +1,66 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
createPluginProjectSnapshot,
} from "./snapshots";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
import { workspace, agent } from "./fixtures";
describe("plugin context snapshots", () => {
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot);
const snapshot = createPluginWorkspaceSnapshot(workspace);
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
expect(snapshot).toMatchObject({
expect(snapshot).toEqual({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -44,7 +90,7 @@ describe("plugin context snapshots", () => {
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
expect(snapshot).toMatchObject({
expect(snapshot).toEqual({
id: "agent-1",
workspaceId: "workspace-1",
provider: "codex",
@@ -73,82 +119,3 @@ describe("plugin context snapshots", () => {
expect(agent.labels).toEqual({ phase: "implementation" });
});
});
describe("sidebar entity snapshots", () => {
it("interns cross-host references and preserves identity across unrelated reads", () => {
const first = createPluginWorkspaceSnapshot(workspace, "host-1");
const second = createPluginWorkspaceSnapshot(workspace, "host-2");
expect(first.ref).not.toBe(second.ref);
expect(first.ref.hostId).toBe("host-1");
expect(first.project.id).toBe(workspace.projectId);
expect(Object.isFrozen(first.labels)).toBe(true);
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first);
expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref);
});
it("invalidates a done workspace when its effective root-agent status changes", () => {
const idle: WorkspaceDescriptor = { ...workspace, status: "done" };
const before = createPluginWorkspaceSnapshot(idle, "host-1");
const activity = new Map([
[idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }],
]);
const after = createPluginWorkspaceSnapshot(idle, "host-1", {
workspaceAgentActivity: activity,
});
expect(before.indicator).toBe("done");
expect(after.status).toBe("done");
expect(after.indicator).toBe("needs_input");
expect(after.indicatorLabel).toBe("Needs input");
expect(after.ref).toBe(before.ref);
expect(
createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }),
).toBe(after);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git" as const,
};
const snapshot = createPluginProjectSnapshot(project, "host-1");
expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot);
expect(snapshot.displayName).toBe("Empty");
expect(snapshot.ref.id).toBe("empty");
expect(Object.isFrozen(snapshot)).toBe(true);
});
});
it("uses the app status labels and invalidates a snapshot on language change", async () => {
i18n.addResourceBundle("fr", "translation", fr);
const previousLanguage = i18n.language;
try {
await i18n.changeLanguage("en");
const english = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(english.indicatorLabel).toBe("Working");
await i18n.changeLanguage("fr");
const french = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(french).not.toBe(english);
expect(french.ref).toBe(english.ref);
expect(french.indicatorLabel).toBe("En cours");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french);
} finally {
await i18n.changeLanguage(previousLanguage);
}
});
it("preserves the host-qualified parent of a remotely delegated agent", () => {
const child = {
...agent,
serverId: "host-2",
parentAgentId: null,
labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" },
};
expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({
hostId: "host-1",
id: "parent",
kind: "agent",
});
});
@@ -1,57 +1,16 @@
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { i18n } from "@/i18n/i18next";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
} from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { entityRefs } from "./refs";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
interface CachedWorkspace {
language: string;
snapshot: PluginWorkspaceSnapshot;
}
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, Map<string, CachedWorkspace>>();
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
export function createPluginWorkspaceSnapshot(
workspace: WorkspaceDescriptor,
hostId: string,
context: Omit<Parameters<typeof deriveEffectiveWorkspaceStatus>[0], "workspace" | "serverId"> & {
parentAgent?: PluginWorkspaceSnapshot["parentAgent"];
} = {},
): PluginWorkspaceSnapshot {
const indicator = deriveEffectiveWorkspaceStatus({
...context,
workspace,
serverId: hostId,
}).status;
const parentAgent = context.parentAgent ?? null;
const cached = workspaceSnapshots.get(workspace)?.get(hostId);
if (
cached &&
cached.language === i18n.language &&
cached.snapshot.indicator === indicator &&
cached.snapshot.parentAgent === parentAgent
)
return cached.snapshot;
const cached = workspaceSnapshots.get(workspace);
if (cached) return cached;
const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null;
const snapshot = Object.freeze({
ref: entityRefs.workspace(hostId, workspace.id),
hostId,
project: entityRefs.project(hostId, workspace.projectId),
pinnedAt: workspace.pinnedAt ?? null,
labels: Object.freeze([...(workspace.labels ?? [])]),
background: workspace.background ?? false,
branch: workspace.gitRuntime?.currentBranch ?? null,
createdAt: serializeTimestamp(workspace.createdAt),
lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt),
parentAgent,
indicator,
indicatorLabel: i18n.t(indicatorLabelKeys[indicator]),
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
@@ -62,25 +21,18 @@ export function createPluginWorkspaceSnapshot(
name: workspace.name,
title: workspace.title ?? null,
status: workspace.status,
statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt),
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
archivingAt: workspace.archivingAt,
diffStat,
});
const byHost = workspaceSnapshots.get(workspace) ?? new Map<string, CachedWorkspace>();
byHost.set(hostId, { language: i18n.language, snapshot });
workspaceSnapshots.set(workspace, byHost);
workspaceSnapshots.set(workspace, snapshot);
return snapshot;
}
export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot {
const cached = agentSnapshots.get(agent)?.get(workspaceId);
if (cached) return cached;
const parent = getParentAddressFromLabels(agent.labels, agent.serverId);
const snapshot = Object.freeze({
ref: entityRefs.agent(agent.serverId, agent.id),
hostId: agent.serverId,
workspace: entityRefs.workspace(agent.serverId, workspaceId),
parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null,
id: agent.id,
workspaceId,
provider: agent.provider,
@@ -103,36 +55,3 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
agentSnapshots.set(agent, byWorkspace);
return snapshot;
}
const indicatorLabelKeys = {
needs_input: "sidebar.statusBucket.needsInput",
failed: "sidebar.statusBucket.failed",
running: "sidebar.statusBucket.working",
attention: "sidebar.statusBucket.readyToReview",
done: "sidebar.statusBucket.done",
} as const;
const projectSnapshots = new WeakMap<ProjectDescriptor, Map<string, PluginProjectSnapshot>>();
export function createPluginProjectSnapshot(
project: ProjectDescriptor,
hostId: string,
): PluginProjectSnapshot {
const cached = projectSnapshots.get(project)?.get(hostId);
if (cached) return cached;
const snapshot = Object.freeze({
ref: entityRefs.project(hostId, project.projectId),
hostId,
displayName: project.projectCustomName ?? project.projectDisplayName,
rootPath: project.projectRootPath,
kind: project.projectKind,
iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null,
});
const byHost = projectSnapshots.get(project) ?? new Map<string, PluginProjectSnapshot>();
byHost.set(hostId, snapshot);
projectSnapshots.set(project, byHost);
return snapshot;
}
function serializeTimestamp(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
@@ -1,55 +0,0 @@
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { createPluginClientStateSource } from "./source";
import { workspace } from "./fixtures";
setFlagsFromString("--expose_gc");
const collectGarbage: () => void = runInNewContext("gc");
setFlagsFromString("--no-expose_gc");
function observeHost() {
const hostId = "removed-plugin-host";
let hosts = [hostId];
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]]));
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => hosts, subscribe: () => () => {} },
});
const release = source.subscribe(() => source.getSnapshot());
source.getSnapshot();
const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces);
const remove = () => {
hosts = [];
store.clearSession(hostId);
};
return { reference, release, remove };
}
it("releases removed hosts' projected data while other plugin readers remain", async () => {
const { reference, release, remove } = observeHost();
remove();
try {
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
} finally {
release();
}
});
it("releases previous projections when the last plugin reader leaves", async () => {
const { reference, release, remove } = observeHost();
release();
remove();
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
});
@@ -1,420 +0,0 @@
import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested";
import { lightTheme } from "@/styles/theme";
import { toPluginTheme } from "../theme";
import { createConnectedPluginHosts } from "./connected-hosts";
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import {
useAgents,
useWorkspaces,
useWorkspace,
useAgent,
type WorkspaceRef,
type AgentRef,
} from "@getpaseo/plugin/client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source";
import { agent, workspace } from "./fixtures";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
const renders = new Map<string, number>();
function count(key: string) {
renders.set(key, (renders.get(key) ?? 0) + 1);
}
function WorkspaceRow({ reference }: { reference: WorkspaceRef }) {
const item = useWorkspace(reference);
count(reference.key);
return (
<div>
{item?.name}:{item?.indicator}
</div>
);
}
function Workspaces() {
count("workspaces");
const items = useWorkspaces({ orderBy: "name" });
return (
<div>
{items.map((reference) => (
<WorkspaceRow key={reference.key} reference={reference} />
))}
</div>
);
}
function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) {
const name = useWorkspace(reference, (item) => ({ name: item.name }));
const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name);
count("selected");
return (
<div>
{name?.name}:{custom?.name}
</div>
);
}
function AgentRow({ reference }: { reference: AgentRef }) {
const item = useAgent(reference);
return <div>{item?.title}</div>;
}
function AgentSection({ section }: { section: string }) {
const agents = useAgents({
where: (item) => (item.requiresAttention ? "waiting" : item.status) === section,
});
count(section);
return (
<div>
{agents.map((reference) => (
<AgentRow key={reference.key} reference={reference} />
))}
</div>
);
}
function Agents() {
return (
<div>
<AgentSection section="running" />
<AgentSection section="waiting" />
<AgentSection section="idle" />
</div>
);
}
function seedHost(hostId: string) {
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(
hostId,
new Map([
["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }],
["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }],
]),
);
store.setProjects(hostId, [
{
projectId: "empty",
projectDisplayName: "Empty project",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git",
},
]);
store.setAgents(
hostId,
new Map<string, Agent>([
[
"agent-1",
{
...agent,
serverId: hostId,
id: "agent-1",
workspaceId: "workspace-1",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
[
"agent-2",
{
...agent,
serverId: hostId,
id: "agent-2",
workspaceId: "workspace-2",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
]),
);
}
it("keeps archived agents readable individually while excluding them from collections", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
const reference = source.getSnapshot().agents[0]!.ref;
useSessionStore.getState().setAgents(
"host-1",
new Map([
[
agent.id,
{
...agent,
archivedAt: new Date("2026-11-01T00:00:00.000Z"),
},
],
]),
);
expect(source.getSnapshot().agents).toEqual([]);
expect(source.getAgent(reference)?.title).toBe(agent.title);
expect(source.getAgent(agent.id)).toBe(source.getAgent(reference));
});
it("demands directories for connected hosts and releases them with its last reader", () => {
const hostId = "plugin-demand-host";
let online = true;
let demands = 0;
const listeners = new Set<() => void>();
const hosts = createConnectedPluginHosts(() => ({
getHosts: () => [{ serverId: hostId }],
getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }),
subscribeAll: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
subscribeHostList: () => () => {},
acquireDirectoryDemand: () => {
demands++;
seedHost(hostId);
return () => {
demands--;
};
},
}));
const source = createPluginClientStateSource(undefined, { hosts });
const first = source.subscribe(() => {});
const second = source.subscribe(() => {});
expect(demands).toBe(1);
expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]);
useSessionStore
.getState()
.setWorkspaces(
hostId,
(previous) =>
new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])),
);
expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([
"Live update",
"Live update",
]);
online = false;
for (const listener of listeners) listener();
expect(demands).toBe(0);
expect(source.getSnapshot().workspaces).toEqual([]);
online = true;
for (const listener of listeners) listener();
expect(demands).toBe(1);
first();
expect(demands).toBe(1);
second();
expect(demands).toBe(0);
expect(listeners.size).toBe(0);
});
function waitForInput() {
useSessionStore.getState().setAgents("host-1", (previous) => {
const current = previous.get("agent-1");
if (!current) throw new Error("Missing test agent");
const next = new Map(previous);
next.set(current.id, {
...current,
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
requiresAttention: true,
attentionReason: "permission",
});
return next;
});
}
class ConnectedHosts implements PluginConnectedHostsSource {
private ids: readonly string[] = ["host-1"];
private listeners = new Set<() => void>();
getSnapshot = () => this.ids;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
connect(id: string) {
this.ids = [...this.ids, id];
for (const listener of this.listeners) listener();
}
}
function mount(children: React.ReactNode, hosts = new ConnectedHosts()) {
const source = createPluginClientStateSource("host-1", { hosts });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(<PluginClientStateProvider source={source}>{children}</PluginClientStateProvider>),
);
return source;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
useSessionStore.getState().clearSession("host-1");
useSessionStore.getState().clearSession("host-2");
renders.clear();
});
it("shows a restored child in the nested example while its cached parent remains archived", () => {
seedHost("host-1");
const parent = {
...agent,
id: "archived-parent",
title: "Archived parent",
archivedAt: new Date("2026-11-01T00:00:00Z"),
};
const child = {
...agent,
id: "restored-child",
title: "Restored child",
parentAgentId: parent.id,
labels: { "paseo.parent-agent-id": parent.id },
archivedAt: parent.archivedAt,
};
const store = useSessionStore.getState();
store.setProjects("host-1", [
{
projectId: workspace.projectId,
projectDisplayName: "Project",
projectCustomName: null,
projectRootPath: workspace.projectRootPath,
projectKind: "git",
},
]);
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, child],
]),
);
const view = {
theme: toPluginTheme(lightTheme),
layout: { compact: false, platform: "web" as const },
active: { workspace: null, agent: null },
navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} },
dismiss() {},
};
const source = mount(<Nested {...view} />);
expect(source.getAgent(parent.id)?.title).toBe(parent.title);
expect(container?.textContent).not.toContain(child.title);
act(() =>
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, { ...child, archivedAt: null }],
]),
),
);
expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull();
expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull();
});
describe("sidebar data hooks through the app store and plugin provider", () => {
it("renders only one workspace row when its root agent starts waiting", () => {
seedHost("host-1");
const source = mount(<Workspaces />);
const first = source.getSnapshot().workspaces[0]!;
const second = source.getSnapshot().workspaces[1]!;
const before = source.getSnapshot();
act(waitForInput);
expect(renders.get("workspaces")).toBe(1);
expect(renders.get(first.ref.key)).toBe(2);
expect(renders.get(second.ref.key)).toBe(1);
expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input");
expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]);
});
it("moves the agent and renders only its two affected sections without memo", () => {
seedHost("host-1");
mount(<Agents />);
act(waitForInput);
expect(renders.get("running")).toBe(2);
expect(renders.get("waiting")).toBe(2);
expect(renders.get("idle")).toBe(1);
});
it("uses shallow selected equality and custom equality without a selector", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
mount(<SelectedWorkspace reference={source.getSnapshot().workspaces[0]!.ref} />);
act(waitForInput);
expect(renders.get("selected")).toBe(1);
});
it("refreshes translated labels through the subscribed source when language changes", async () => {
seedHost("host-1");
i18n.addResourceBundle("fr", "translation", fr);
const source = mount(<Workspaces />);
const before = source.getSnapshot().workspaces[0]!;
const previousLanguage = i18n.language;
try {
await act(async () => {
await i18n.changeLanguage("fr");
});
const after = source.getSnapshot().workspaces[0]!;
expect(after).not.toBe(before);
expect(after.indicatorLabel).toBe("En cours");
expect(after.ref).toBe(before.ref);
expect(renders.get(before.ref.key)).toBe(2);
expect(renders.get("workspaces")).toBe(1);
} finally {
await act(async () => {
await i18n.changeLanguage(previousLanguage);
});
}
});
it("adds a newly connected host and exposes projects with no workspaces", () => {
seedHost("host-1");
seedHost("host-2");
const hosts = new ConnectedHosts();
const source = mount(<Workspaces />, hosts);
expect(source.getSnapshot().workspaces).toHaveLength(2);
expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project");
act(() => hosts.connect("host-2"));
expect(source.getSnapshot().workspaces).toHaveLength(4);
expect(source.getSnapshot().projects).toHaveLength(2);
expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4);
});
});
it("observes terminals only while a terminal reader is subscribed", () => {
let active = 0;
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => {
active++;
return () => {
active--;
};
},
},
});
const releaseWorkspace = source.subscribe(() => {});
expect(active).toBe(0);
const releaseTerminal = source.subscribe(() => {}, { terminals: true });
const releaseSecond = source.subscribe(() => {}, { terminals: true });
expect(active).toBe(1);
releaseTerminal();
expect(active).toBe(1);
releaseSecond();
expect(active).toBe(0);
releaseWorkspace();
});
it("terminal row reads propagate only the referenced host's observation error", () => {
const error = new Error("Update the host");
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => () => {},
getErrors: () => new Map([["unsupported", error]]),
},
});
const refs = createEntityRefs();
expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error);
expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull();
});
+14 -241
View File
@@ -1,248 +1,21 @@
import { i18n } from "@/i18n/i18next";
import type { PluginClientSnapshot, PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import { useSessionStore } from "@/stores/session-store";
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { connectedPluginHosts } from "./connected-hosts";
import { pluginTerminals } from "./terminals";
import { entityRefs } from "./refs";
import {
createPluginAgentSnapshot,
createPluginProjectSnapshot,
createPluginWorkspaceSnapshot,
} from "./snapshots";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
export interface PluginConnectedHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly string[];
}
export interface PluginTerminalsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly PluginTerminalSnapshot[];
getErrors?(): ReadonlyMap<string, Error>;
}
const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]);
const listeners = new Set<() => void>();
let releaseStores: (() => void) | null = null;
// Every plugin host shares one subscription to each app store.
function subscribeStores(listener: () => void): () => void {
if (!releaseStores) {
const notify = () => {
for (const subscriber of listeners) subscriber();
};
i18n.on("languageChanged", notify);
const releaseSession = useSessionStore.subscribe((state) => {
for (const hostId of hostProjections.keys()) {
if (!state.sessions[hostId]) hostProjections.delete(hostId);
}
notify();
});
const releaseCreate = useCreateFlowStore.subscribe(notify);
releaseStores = () => {
i18n.off("languageChanged", notify);
releaseSession();
releaseCreate();
};
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && releaseStores) {
releaseStores();
releaseStores = null;
hostProjections.clear();
}
};
}
interface HostProjection {
language: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
projects: SessionState["projects"];
activity: SessionState["workspaceAgentActivity"];
pending: ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
snapshot: Omit<PluginClientSnapshot, "terminals">;
workspaceById: Map<string, PluginClientSnapshot["workspaces"][number]>;
agentById: Map<string, PluginClientSnapshot["agents"][number]>;
projectById: Map<string, PluginClientSnapshot["projects"][number]>;
}
const hostProjections = new Map<string, HostProjection>();
function projectHost(hostId: string): HostProjection | null {
const session = useSessionStore.getState().sessions[hostId];
if (!session) {
hostProjections.delete(hostId);
return null;
}
const pending = useCreateFlowStore.getState().pendingByDraftId;
const previous = hostProjections.get(hostId);
const unchanged =
previous &&
previous.language === i18n.language &&
previous.workspaces === session.workspaces &&
previous.agents === session.agents &&
previous.projects === session.projects &&
previous.activity === session.workspaceAgentActivity &&
previous.pending === pending;
if (unchanged) return previous;
const workspaces = Array.from(session.workspaces.values(), (workspace) => {
const activity = session.workspaceAgentActivity.get(workspace.id);
const root = activity ? session.agents.get(activity.agentId) : undefined;
return createPluginWorkspaceSnapshot(workspace, hostId, {
pendingCreateAttempts: pending,
workspaceAgentActivity: session.workspaceAgentActivity,
parentAgent: root ? createPluginAgentSnapshot(root, workspace.id).parentAgent : null,
});
});
const agents: PluginClientSnapshot["agents"][number][] = [];
for (const agent of session.agents.values()) {
const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (!workspaceId || agent.archivedAt) continue;
agents.push(createPluginAgentSnapshot(agent, workspaceId));
}
const projects = Array.from(session.projects.values(), (project) =>
createPluginProjectSnapshot(project, hostId),
);
const projection: HostProjection = {
language: i18n.language,
workspaces: session.workspaces,
agents: session.agents,
projects: session.projects,
activity: session.workspaceAgentActivity,
pending,
snapshot: {
workspaces: Object.freeze(workspaces),
agents: Object.freeze(agents),
projects: Object.freeze(projects),
},
workspaceById: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
agentById: new Map(agents.map((agent) => [agent.id, agent])),
projectById: new Map(projects.map((project) => [project.ref.id, project])),
};
hostProjections.set(hostId, projection);
return projection;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/** A host scope only resolves legacy string ids. Ref lookups and collections span hosts. */
export function createPluginClientStateSource(
serverId?: string,
options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {},
): PluginClientStateSource {
const terminals = options.terminals ?? pluginTerminals;
const hosts = options.hosts ?? connectedPluginHosts;
const subscribers = new Set<() => void>();
const terminalSubscribers = new Set<() => void>();
let releaseTerminals: (() => void) | null = null;
let release: (() => void) | null = null;
let previousProjections: readonly HostProjection[] = [];
let previousTerminals = emptyTerminals;
let previousTerminalErrors: ReadonlyMap<string, Error> | undefined;
let snapshot: PluginClientSnapshot = {
workspaces: [],
agents: [],
projects: [],
terminals: emptyTerminals,
};
function getSnapshot(): PluginClientSnapshot {
const ids = hosts.getSnapshot();
const projections: HostProjection[] = [];
for (const id of ids) {
const projection = projectHost(id);
if (projection) projections.push(projection);
}
const terminalSnapshots = terminals.getSnapshot();
const terminalErrors = terminals.getErrors?.();
if (
sameSequence(previousProjections, projections) &&
previousTerminals === terminalSnapshots &&
previousTerminalErrors === terminalErrors
)
return snapshot;
previousProjections = projections;
previousTerminals = terminalSnapshots;
previousTerminalErrors = terminalErrors;
snapshot = Object.freeze({
workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)),
agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)),
projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)),
terminals: terminalSnapshots,
terminalErrors,
});
return snapshot;
}
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
return {
subscribe(listener, subscriptionOptions) {
subscribers.add(listener);
if (subscriptionOptions?.terminals) {
terminalSubscribers.add(listener);
releaseTerminals ??= terminals.subscribe(() => {
for (const subscriber of terminalSubscribers) subscriber();
});
}
if (!release) {
const notify = () => {
for (const subscriber of subscribers) subscriber();
};
const releaseStore = subscribeStores(notify);
const releaseHosts = hosts.subscribe(notify);
release = () => {
releaseStore();
releaseHosts();
};
}
return () => {
subscribers.delete(listener);
terminalSubscribers.delete(listener);
if (terminalSubscribers.size === 0 && releaseTerminals) {
releaseTerminals();
releaseTerminals = null;
}
if (subscribers.size === 0 && release) {
release();
release = null;
previousProjections = [];
previousTerminals = emptyTerminals;
previousTerminalErrors = undefined;
snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals };
}
};
subscribe: useSessionStore.subscribe,
getWorkspace(workspaceId) {
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
},
getSnapshot,
getWorkspace(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.workspace(serverId, reference)
: reference;
if (typeof ref === "string") return null;
return projectHost(ref.hostId)?.workspaceById.get(ref.id) ?? null;
},
getAgent(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.agent(serverId, reference)
: reference;
if (typeof ref === "string") return null;
const projected = projectHost(ref.hostId)?.agentById.get(ref.id);
if (projected) return projected;
const session = useSessionStore.getState().sessions[ref.hostId];
const detail = session?.agents.get(ref.id) ?? session?.agentDetails.get(ref.id);
const workspaceId = normalizeWorkspaceOpaqueId(detail?.workspaceId);
return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null;
},
getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null,
getTerminal(ref) {
const error = terminals.getErrors?.().get(ref.hostId);
if (error) throw error;
return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null;
getAgent(agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId);
return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null;
},
};
}
@@ -1,154 +0,0 @@
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TerminalsChanged } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { entityRefs } from "./refs";
import type { PluginTerminalsSource } from "./source";
interface TerminalHost {
serverId: string;
client: DaemonClient;
epoch: number;
}
export interface TerminalHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly TerminalHost[];
}
interface Observation {
client: DaemonClient;
epoch: number;
items: readonly PluginTerminalSnapshot[];
release(): void;
}
/** One owned observation per connected host, shared by every plugin surface. */
export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource {
const observations = new Map<string, Observation>();
const listeners = new Set<() => void>();
let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]);
let errors: ReadonlyMap<string, Error> = new Map();
let releaseHosts: (() => void) | null = null;
function publish() {
const next = [...observations.values()].flatMap((item) => item.items);
if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index]))
snapshot = Object.freeze(next);
for (const listener of listeners) listener();
}
function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) {
if (observations.get(hostId) !== observation) return;
const previous = new Map(observation.items.map((item) => [item.ref.id, item]));
observation.items = Object.freeze(
payload.terminals.flatMap((terminal) => {
// Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace.
if (!terminal.workspaceId) return [];
const ref = entityRefs.terminal(hostId, terminal.id);
const workspace = entityRefs.workspace(hostId, terminal.workspaceId);
const title = terminal.title ?? terminal.name;
const createdAt = terminal.createdAt ?? null;
const isScript = terminal.isScript === true;
const cached = previous.get(terminal.id);
if (
cached &&
cached.workspace === workspace &&
cached.title === title &&
cached.createdAt === createdAt &&
cached.isScript === isScript
)
return [cached];
return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })];
}),
);
errors = new Map([...errors].filter(([id]) => id !== hostId));
publish();
}
function reconcile() {
const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host]));
for (const [id, observation] of observations) {
if (
active.get(id)?.client !== observation.client ||
active.get(id)?.epoch !== observation.epoch
) {
observations.delete(id);
observation.release();
}
}
errors = new Map([...errors].filter(([id]) => active.has(id)));
for (const [hostId, { client, epoch }] of active) {
if (observations.has(hostId)) continue;
const observation: Observation = { client, epoch, items: [], release() {} };
observations.set(hostId, observation);
try {
// The SDK owns the capability gate; a failed host is reported only to terminal readers.
const subscription = client.observeTerminals({});
const unsubscribe = subscription.subscribe({
snapshot: (payload) => receive(hostId, observation, payload),
update: (message) => {
if (message.type === "terminals_changed") receive(hostId, observation, message.payload);
},
error: (error) => {
if (observations.get(hostId) !== observation) return;
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
publish();
},
});
void subscription.ready.catch(() => {}); // Observer owns error delivery.
observation.release = () => {
unsubscribe();
void subscription.release().catch(() => {});
};
} catch (error) {
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
}
}
publish();
}
return {
getSnapshot: () => snapshot,
getErrors: () => errors,
subscribe(listener) {
listeners.add(listener);
if (!releaseHosts) {
releaseHosts = hosts.subscribe(reconcile);
reconcile();
}
return () => {
listeners.delete(listener);
if (!listeners.size && releaseHosts) {
releaseHosts();
releaseHosts = null;
for (const observation of observations.values()) observation.release();
observations.clear();
snapshot = Object.freeze([]);
errors = new Map();
}
};
},
};
}
export const pluginTerminals = createPluginTerminalsSource({
getSnapshot() {
const runtime = getHostRuntimeStore();
return runtime.getHosts().flatMap((host) => {
const state = runtime.getSnapshot(host.serverId);
return state?.connectionStatus === "online" && state.client
? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }]
: [];
});
},
subscribe(listener) {
const runtime = getHostRuntimeStore();
const releaseRuntime = runtime.subscribeAll(listener);
const releaseHosts = runtime.subscribeHostList(listener);
return () => {
releaseRuntime();
releaseHosts();
};
},
});
@@ -13,20 +13,7 @@ import { z } from "zod";
import type { InstalledPlugin } from "../types";
import { buildPluginCommandCenterContributions } from "./contributions";
import { entityRefs } from "../client-state/refs";
const workspace: PluginWorkspaceSnapshot = {
ref: entityRefs.workspace("host-1", "workspace-1"),
hostId: "host-1",
project: entityRefs.project("host-1", "project-1"),
pinnedAt: null,
labels: [],
background: false,
branch: null,
createdAt: null,
lastPromptAt: null,
parentAgent: null,
indicator: "running",
indicatorLabel: "Running",
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -43,10 +30,6 @@ const workspace: PluginWorkspaceSnapshot = {
};
const agent: PluginAgentSnapshot = {
ref: entityRefs.agent("host-1", "agent-1"),
hostId: "host-1",
workspace: workspace.ref,
parentAgent: null,
id: "agent-1",
workspaceId: workspace.id,
provider: "codex",
@@ -99,7 +82,6 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
cleanup: () => {},
settingsScreens: [],
surfaces: [{ id: "main", title: "Main", Component: () => null }],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -149,15 +131,6 @@ function stateSource() {
subscribe() {
return () => {};
},
getSnapshot() {
return { workspaces: [workspace], agents: [agent], projects: [], terminals: [] };
},
getProject() {
return null;
},
getTerminal() {
return null;
},
getWorkspace(id: string) {
return id === workspace.id ? workspace : null;
},
-23
View File
@@ -55,29 +55,6 @@ describe("evaluatePluginClientBundle", () => {
expect(active).toBe(0);
});
it("registers sidebar views without selecting one and removes them on cleanup", async () => {
const plugin = evaluatePluginClientBundle(
"sidebar",
bundle(`
plugin.addSidebarView({ id: "workspaces", title: "Workspaces", Component() { return null; } });
`),
);
expect(plugin.sidebarViews.map((view) => view.id)).toEqual(["workspaces"]);
await plugin.cleanup();
expect(plugin.sidebarViews).toEqual([]);
});
it("rejects duplicate sidebar view ids", () => {
expect(() =>
evaluatePluginClientBundle(
"sidebar",
bundle(`
const view = { id: "workspaces", title: "Workspaces", Component() { return null; } };
plugin.addSidebarView(view); plugin.addSidebarView(view);
`),
),
).toThrow("Duplicate sidebar view");
});
it("accepts memoized settings screens", () => {
const plugin = evaluatePluginClientBundle(
"settings",
+1 -17
View File
@@ -129,7 +129,6 @@ export function runPluginClientBundle(
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
surfaces: [],
settingsScreens: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -142,7 +141,6 @@ export function runPluginClientBundle(
timelineRenderers: [],
};
const surfaceIds = new Set<string>();
const sidebarViewIds = new Set<string>();
const settingsScreenIds = new Set<string>();
const sidebarItemIds: Record<PluginSidebarSection, Set<string>> = {
header: new Set(),
@@ -272,18 +270,6 @@ export function runPluginClientBundle(
() => surfaceIds.delete(normalizedId),
);
},
addSidebarView(contribution) {
const normalizedId = requireId(contribution.id, "sidebar view id");
if (sidebarViewIds.has(normalizedId))
throw new Error(`Duplicate sidebar view: ${normalizedId}`);
if (!isComponentType(contribution.Component))
throw new Error(`Sidebar view ${normalizedId} is not a component`);
const title = requirePluginText(contribution.title);
sidebarViewIds.add(normalizedId);
return register(collector.sidebarViews, { ...contribution, id: normalizedId, title }, () =>
sidebarViewIds.delete(normalizedId),
);
},
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("header", contribution);
},
@@ -532,8 +518,7 @@ export function runPluginClientBundle(
if (name === "@getpaseo/plugin/client")
return {
...pluginClientRuntime,
useSettings: (definition: import("@getpaseo/plugin").SettingsDefinition) =>
useSettings(definition, id),
useSettings,
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
useHosts: () =>
@@ -598,7 +583,6 @@ export function runPluginClientBundle(
cleanup,
surfaces: collector.surfaces,
settingsScreens: collector.settingsScreens,
sidebarViews: collector.sidebarViews,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
filePanels: collector.filePanels,
@@ -1,8 +1,7 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
import { defineRpc } from "@getpaseo/plugin";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PaseoApi } from "@getpaseo/client";
import { QueryClient } from "@tanstack/react-query";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -25,13 +24,7 @@ function installation(invoked: string[]): InstalledPlugin {
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
paseo: createPaseoApi(
new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "installation-provider",
reconnect: { enabled: false },
}),
),
paseo: { dispose: async () => undefined } as PaseoApi,
invoke: async (method) => {
invoked.push(method);
return { state: "green" };
@@ -43,7 +36,6 @@ function installation(invoked: string[]): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -83,6 +75,4 @@ it("gives every surface the installation's client and RPCs on its first frame",
expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true);
await expect(rpc!({})).resolves.toEqual({ state: "green" });
expect(invoked).toEqual(["deploys.status"]);
plugin.lifetime.abort();
await plugin.paseo.dispose();
});
@@ -1,4 +1,3 @@
import { getPluginClientApi } from "./client-state/api";
import { QueryClientProvider } from "@tanstack/react-query";
import {
PaseoApiProvider,
@@ -18,7 +17,7 @@ export function PluginInstallationProvider({
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={getPluginClientApi(plugin).paseo}>
<PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
@@ -1,4 +1,3 @@
import { getPluginClientApi } from "./client-state/api";
import { filePanelTitle } from "@/file-panels/choice/presentation";
import { i18n } from "@/i18n/i18next";
import { createPluginHosts } from "./hosts";
@@ -51,7 +50,7 @@ function registry(
},
installation.lifetime.signal,
),
paseo: getPluginClientApi(installation).paseo,
paseo: installation.paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected plugin RPC");
@@ -1,4 +1,3 @@
import { getPluginClientApi } from "./client-state/api";
import { createPluginHosts } from "./hosts";
import { afterEach, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -26,7 +25,7 @@ function registry(version: string) {
},
installation.lifetime.signal,
),
paseo: getPluginClientApi(installation).paseo,
paseo: installation.paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("No RPC in this plugin");
-1
View File
@@ -122,7 +122,6 @@ export class PluginRegistry {
cleanup: () => undefined,
surfaces: [],
settingsScreens: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
import { createDeviceSettings } from "./device";
function device(records = new Map<string, string>()) {
return createDeviceSettings({
getItem: async (key) => records.get(key) ?? null,
setItem: async (key, value) => {
records.set(key, value);
},
});
}
const definition = defineSettings({
id: "layout",
scope: "device",
version: 1,
schema: z.object({ collapsed: z.array(z.string()).default([]) }),
});
describe("device plugin settings", () => {
it("shares one installation across hosts and keeps other installations separate", async () => {
const settings = device();
const first = settings("plugin", definition);
const second = settings("plugin", definition);
const before = await first.read();
expect(before).toEqual({ status: "ready", revision: "missing", values: { collapsed: [] } });
const saved = await first.write({ revision: before.revision, values: { collapsed: ["work"] } });
expect(saved.status).toBe("saved");
expect(await second.read()).toMatchObject({ status: "ready", values: { collapsed: ["work"] } });
expect(await settings("other", definition).read()).toEqual(before);
});
it("serializes competing writes and resets through the same revision contract", async () => {
const settings = device()("plugin", definition);
const initial = await settings.read();
const results = await Promise.all([
settings.write({ revision: initial.revision, values: { collapsed: ["a"] } }),
settings.write({ revision: initial.revision, values: { collapsed: ["b"] } }),
]);
expect(results.map((result) => result.status)).toEqual(["saved", "conflict"]);
const current = await settings.read();
expect(await settings.reset({ revision: current.revision })).toMatchObject({
status: "saved",
values: { collapsed: [] },
});
});
it("validates writes and migrates saved layout values", async () => {
const settings = device();
const first = settings("plugin", definition);
expect(await first.write({ revision: "missing", values: { collapsed: 3 } })).toMatchObject({
status: "invalid",
});
await first.write({ revision: "missing", values: { collapsed: ["a"] } });
const upgraded = settings(
"plugin",
defineSettings({
...definition,
version: 2,
migrate: (_values, from) => ({ collapsed: [`v${from}`] }),
}),
);
expect(await upgraded.read()).toMatchObject({ status: "ready", values: { collapsed: ["v1"] } });
});
});
it("persists a validated migration once and returns the persisted revision across restarts", async () => {
const records = new Map<string, string>();
const settings = device(records);
await settings("plugin", definition).write({ revision: "missing", values: { collapsed: ["a"] } });
let migrations = 0;
const upgraded = defineSettings({
...definition,
version: 2,
migrate: () => {
migrations++;
return { collapsed: ["migrated"] };
},
});
const first = await settings("plugin", upgraded).read();
const restarted = device(records)("plugin", upgraded);
expect(await restarted.read()).toEqual(first);
expect(migrations).toBe(1);
expect(first).toEqual({
status: "ready",
revision: JSON.stringify({ version: 2, values: { collapsed: ["migrated"] } }),
values: { collapsed: ["migrated"] },
});
expect(
await restarted.write({
revision: JSON.stringify({ version: 1, values: { collapsed: ["a"] } }),
values: { collapsed: [] },
}),
).toMatchObject({ status: "conflict" });
expect(
await restarted.write({ revision: first.revision, values: { collapsed: [] } }),
).toMatchObject({ status: "saved" });
});
@@ -1,80 +0,0 @@
import {
settingsRpc,
type SettingsDefinition,
type RpcOutput,
type RpcInput,
} from "@getpaseo/plugin";
import { z } from "zod";
interface DeviceStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
type ReadResult = RpcOutput<ReturnType<typeof settingsRpc>["read"]>;
type WriteResult = RpcOutput<ReturnType<typeof settingsRpc>["write"]>;
type WriteInput = RpcInput<ReturnType<typeof settingsRpc>["write"]>;
const envelope = z.object({ version: z.number().int().positive(), values: z.json() });
/** One writer per device; installations with the same id share their layout settings across hosts. */
export function createDeviceSettings(storage: DeviceStorage) {
let queue: Promise<unknown> = Promise.resolve();
function serial<T>(work: () => Promise<T>): Promise<T> {
const result = queue.then(work);
queue = result.catch(() => undefined);
return result;
}
return function settings(installationId: string, definition: SettingsDefinition) {
const key = `@paseo:plugin-device-settings:${installationId}/${definition.id}`;
async function read(): Promise<ReadResult> {
const raw = await storage.getItem(key);
let revision = raw ?? "missing";
try {
let values: unknown = {};
let migrated = false;
if (raw !== null) {
const stored = envelope.parse(JSON.parse(raw));
if (stored.version > definition.version)
throw new Error("Update this plugin to read its newer settings");
values = stored.values;
if (stored.version < definition.version) {
if (!definition.migrate)
throw new Error("This plugin has no migration for its saved settings");
values = await definition.migrate(values, stored.version);
migrated = true;
}
}
const parsed = z.json().parse(await definition.schema.parseAsync(values));
if (migrated) {
const serialized = JSON.stringify({ version: definition.version, values: parsed });
await storage.setItem(key, serialized);
revision = serialized;
}
return { status: "ready", revision, values: parsed };
} catch (error) {
return {
status: "invalid",
revision,
error: error instanceof Error ? error.message : String(error),
};
}
}
function write(input: WriteInput): Promise<WriteResult> {
return serial(async () => {
const raw = await storage.getItem(key);
if ((raw ?? "missing") !== input.revision)
return { status: "conflict", error: "Settings changed. Reload before saving." };
const parsed = await definition.schema.safeParseAsync(input.values);
if (!parsed.success) return { status: "invalid", error: parsed.error.message };
const values = z.json().parse(parsed.data);
const serialized = JSON.stringify({ version: definition.version, values });
await storage.setItem(key, serialized);
return { status: "saved", revision: serialized, values };
});
}
return {
read: () => serial(read),
write,
reset: (input: { revision: string }) => write({ revision: input.revision, values: {} }),
};
};
}
@@ -1,6 +1,3 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { queryClient as appQueryClient } from "@/data/query-client";
import { createDeviceSettings } from "./device";
import type { SettingsState } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -11,8 +8,6 @@ import { useReplicaQuery } from "@/data/query";
import { z, type ZodType } from "zod";
import { pluginSettingsKey } from "./query";
const deviceSettings = createDeviceSettings(AsyncStorage);
function message(error: unknown): string {
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
return error instanceof Error ? error.message : String(error);
@@ -20,49 +15,34 @@ function message(error: unknown): string {
export function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
installationId: string,
): SettingsState<Schema> {
const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]);
const read = useRpc(rpc.read);
const write = useRpc(rpc.write);
const reset = useRpc(rpc.reset);
const installationClient = useQueryClient();
const device = definition.scope === "device";
const client = device ? appQueryClient : installationClient;
const key = device
? ["plugin-device-settings", installationId, definition.id, definition.version]
: pluginSettingsKey(definition.id);
const local = useMemo(
() => deviceSettings(installationId, definition),
[installationId, definition],
);
const query = useReplicaQuery(
{
queryKey: key,
pushEvent: device ? "plugin_device_settings_saved" : "plugin_settings_changed",
queryFn: () => (device ? local.read() : read({})),
retry: false,
const client = useQueryClient();
const key = pluginSettingsKey(definition.id);
const query = useReplicaQuery({
queryKey: key,
pushEvent: "plugin_settings_changed",
queryFn: () => read({}),
retry: false,
});
const mutation = useMutation({
mutationFn: async (
input: { revision: string; values: z.output<Schema> } | { revision: string },
) => {
const result =
"values" in input
? await write({
revision: input.revision,
values: z.json().parse(await definition.schema.parseAsync(input.values)),
})
: await reset(input);
if (result.status !== "saved") throw new Error(result.error);
client.setQueryData(key, { ...result, status: "ready" });
},
client,
);
const mutation = useMutation(
{
mutationFn: async (
input: { revision: string; values: z.output<Schema> } | { revision: string },
) => {
const result =
"values" in input
? await (device ? local.write : write)({
revision: input.revision,
values: z.json().parse(await definition.schema.parseAsync(input.values)),
})
: await (device ? local.reset : reset)(input);
if (result.status !== "saved") throw new Error(result.error);
client.setQueryData(key, { ...result, status: "ready" });
},
},
client,
);
});
async function save(values: z.output<Schema>, revision: string) {
try {
await mutation.mutateAsync({ values, revision });
@@ -31,7 +31,6 @@ function installed(
},
settingsScreens: [],
surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
sidebarViews: [],
sidebarItems: {
header: [{ id: contributionId, title: "Example", Component: () => null }],
footer: [{ id: "status", title: "Status", Component: () => null }],
@@ -1,25 +0,0 @@
import type { InstalledPlugin } from "../types";
export interface SidebarViewEntry {
id: string;
title: string;
plugin: InstalledPlugin;
view: InstalledPlugin["sidebarViews"][number];
}
export function sidebarViews(plugins: readonly InstalledPlugin[]): SidebarViewEntry[] {
const entries = new Map<string, SidebarViewEntry>();
for (const plugin of plugins) {
for (const view of plugin.sidebarViews) {
const id = `${plugin.id}/${view.id}`;
if (!entries.has(id))
entries.set(id, {
id,
title: plugin.i18n.resolve(view.title, plugin.locale),
plugin,
view,
});
}
}
return [...entries.values()];
}
@@ -1,69 +0,0 @@
import React, { act, useMemo } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, expect, it } from "vitest";
import { SidebarViewFrame } from "./frame";
import { useSidebarViewSelection, type SidebarViewStorage } from "./selection";
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let defaultRenders = 0;
function DefaultView() {
defaultRenders++;
return <div>Default sidebar</div>;
}
function PluginView() {
return <div>Stored plugin sidebar</div>;
}
function Sidebar({ storage }: { storage: SidebarViewStorage }) {
const selection = useSidebarViewSelection(storage);
const fallback = useMemo(() => <DefaultView />, []);
const selected =
selection.state.status === "ready" && selection.state.selection === "example/workspaces";
const view = selected ? <PluginView /> : null;
return (
<SidebarViewFrame state={selection.state} fallback={fallback} reload={selection.reload}>
{view}
</SidebarViewFrame>
);
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
defaultRenders = 0;
client.clear();
});
function deferredStorage() {
let finishRead: (value: string) => void = () => {
throw new Error("Read not started");
};
const read = new Promise<string>((resolve) => {
finishRead = resolve;
});
const storage: SidebarViewStorage = { getItem: () => read, setItem: async () => {} };
return { storage, finishRead };
}
it("never renders the default before a stored plugin selection is known", async () => {
const { storage, finishRead } = deferredStorage();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () =>
root?.render(
<QueryClientProvider client={client}>
<Sidebar storage={storage} />
</QueryClientProvider>,
),
);
expect(defaultRenders).toBe(0);
await act(async () => {
finishRead("example/workspaces");
});
await expect.poll(() => container?.textContent).toContain("Stored plugin sidebar");
expect(defaultRenders).toBe(0);
});
@@ -1,34 +0,0 @@
import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton";
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import type { SidebarViewSelectionState } from "./selection";
export function SidebarViewFrame({
state,
fallback,
children,
reload,
}: {
state: SidebarViewSelectionState;
fallback: ReactNode;
children: ReactNode;
reload(): void;
}) {
const { t } = useTranslation();
if (state.status === "loading") return <SidebarAgentListSkeleton />;
if (state.status === "error")
return (
<View style={styles.failure}>
<Text style={styles.text}>{t("sidebar.view.readFailed")}</Text>
<Button onPress={reload}>{t("common.actions.retry")}</Button>
</View>
);
return children ?? fallback;
}
const styles = StyleSheet.create((theme) => ({
failure: { padding: theme.spacing[4], gap: theme.spacing[3] },
text: { color: theme.colors.foreground },
}));
@@ -1,173 +0,0 @@
import { useTranslation } from "react-i18next";
import { SidebarViewFrame } from "./frame";
import type { PluginSidebarViewProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { router } from "expo-router";
import { useMemo, useCallback, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { entityRefs } from "../client-state/refs";
import { PluginEnvironmentProvider } from "../popover";
import { resolvePluginPlatform } from "../platform";
import { useInstalledPlugins } from "../registry";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import { sidebarViews, type SidebarViewEntry } from "./catalog";
import { useSidebarViewSelection } from "./selection";
const source = createPluginClientStateSource();
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function SidebarViewSlot({ children, dismiss }: { children: ReactNode; dismiss(): void }) {
const { t } = useTranslation();
const installed = useInstalledPlugins();
const { state, reload, setSelection, pending, error } = useSidebarViewSelection();
const restoreDefault = useCallback(() => setSelection("default"), [setSelection]);
const renderError = useCallback(
() => (
<View style={styles.placeholder}>
<Text style={styles.text}>{t("sidebar.view.failed")}</Text>
<Button onPress={restoreDefault} disabled={pending}>
{t("sidebar.view.useDefault")}
</Button>
{error ? <Text style={styles.text}>{error.message}</Text> : null}
</View>
),
[restoreDefault, pending, error, t],
);
const entry =
state.status === "ready"
? sidebarViews(installed).find((view) => view.id === state.selection)
: undefined;
const view = entry ? (
<View style={styles.content} testID="plugin-sidebar-view">
<SurfaceErrorBoundary
installation={entry.plugin}
Surface={entry.view.Component}
renderError={renderError}
>
<ThemedSidebarView entry={entry} dismiss={dismiss} uniProps={themeMapping} />
</SurfaceErrorBoundary>
</View>
) : null;
return (
<SidebarViewFrame state={state} fallback={children} reload={reload}>
{view}
</SidebarViewFrame>
);
}
function SidebarView({
entry,
dismiss,
theme,
}: {
entry: SidebarViewEntry;
dismiss(): void;
theme: PluginTheme;
}) {
const { t } = useTranslation();
const toast = useToast();
const compact = useIsCompactFormFactor();
const selection = useActiveWorkspaceSelection();
const focusedAgentId = useSessionStore((state) =>
selection ? (state.sessions[selection.serverId]?.focusedAgentId ?? null) : null,
);
const activeWorkspace = selection
? entityRefs.workspace(selection.serverId, selection.workspaceId)
: null;
const activeAgent =
selection && focusedAgentId ? entityRefs.agent(selection.serverId, focusedAgentId) : null;
const environment = useMemo(
() => ({ installation: entry.plugin, state: source, toast }),
[entry.plugin, toast],
);
const navigation = useMemo<PluginSidebarViewProps["navigation"]>(
() => ({
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
dismiss();
},
openAgent(ref) {
const agent = source.getAgent(ref);
if (!agent) {
toast.error(t("sidebar.view.agentUnavailable"));
return;
}
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
dismiss();
},
openTerminal(ref) {
const terminal = source.getTerminal(ref);
if (!terminal) {
toast.error(t("sidebar.view.terminalUnavailable"));
return;
}
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
dismiss();
},
openProject(ref) {
const project = source.getProject(ref);
if (!project) {
toast.error(t("sidebar.view.projectUnavailable"));
return;
}
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
dismiss();
},
}),
[dismiss, toast, t],
);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const active = useMemo(
() => ({ workspace: activeWorkspace, agent: activeAgent }),
[activeWorkspace, activeAgent],
);
const Component = entry.view.Component;
return (
<PluginEnvironmentProvider environment={environment}>
<Component
theme={theme}
layout={layout}
active={active}
navigation={navigation}
dismiss={dismiss}
/>
</PluginEnvironmentProvider>
);
}
const ThemedSidebarView = withUnistyles(SidebarView);
const styles = StyleSheet.create((theme) => ({
content: { flex: 1 },
placeholder: { padding: theme.spacing[4], gap: theme.spacing[3] },
text: { color: theme.colors.foreground },
}));
export { SidebarViewPicker } from "./picker";
@@ -1,58 +0,0 @@
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsRow, SettingsSection, SettingsSelect } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { useInstalledPlugins } from "../registry";
import { sidebarViews } from "./catalog";
import { useSidebarViewSelection } from "./selection";
export function SidebarViewPicker() {
const { t } = useTranslation();
const views = sidebarViews(useInstalledPlugins());
const { state, reload, setSelection, pending, error } = useSidebarViewSelection();
let content;
if (state.status === "loading") {
content = (
<SettingsRow label={t("settings.appearance.sidebar.view.label")}>
<Text style={styles.message}>{t("common.loading")}</Text>
</SettingsRow>
);
} else if (state.status === "error") {
content = (
<SettingsRow
label={t("settings.appearance.sidebar.view.label")}
hint={t("sidebar.view.readFailed")}
>
<Button onPress={reload}>{t("common.actions.retry")}</Button>
</SettingsRow>
);
} else {
const missing =
state.selection !== "default" && !views.some((view) => view.id === state.selection);
const value = missing ? "default" : state.selection;
let hint: string | undefined;
if (missing) hint = t("settings.appearance.sidebar.view.unavailable");
if (error) hint = error.message;
const options = [
{ value: "default", label: t("settings.appearance.sidebar.view.default") },
...views.map((view) => ({ value: view.id, label: view.title })),
];
content = (
<SettingsSelect
label={t("settings.appearance.sidebar.view.label")}
value={value}
options={options}
onValueChange={setSelection}
disabled={pending}
hint={hint}
/>
);
}
return (
<SettingsSection title={t("settings.appearance.sidebar.view.title")}>
<SettingsCard>{content}</SettingsCard>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
@@ -1,47 +0,0 @@
import { useCallback } from "react";
import { useFetchQuery } from "@/data/query";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQueryClient } from "@tanstack/react-query";
const key = "sidebar.view";
const queryKey = [key];
export interface SidebarViewStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
export type SidebarViewSelectionState =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "ready"; selection: string };
export function useSidebarViewSelection(storage: SidebarViewStorage = AsyncStorage) {
const client = useQueryClient();
const query = useFetchQuery({
queryKey,
queryFn: async () => (await storage.getItem(key)) ?? "default",
dataShape: "value",
immutableWhen: () => true,
});
const save = useMutation({
mutationFn: (selection: string) => storage.setItem(key, selection),
onSuccess: (_result, selection) => {
client.setQueryData(queryKey, selection);
},
});
let state: SidebarViewSelectionState;
if (query.isPending) state = { status: "loading" };
else if (query.isError) state = { status: "error", error: query.error };
else state = { status: "ready", selection: query.data };
const { refetch } = query;
const reload = useCallback(() => {
void refetch();
}, [refetch]);
return {
state,
reload,
setSelection: save.mutate,
pending: save.isPending,
error: save.error,
};
}
@@ -49,7 +49,6 @@ function installation(
cleanup: () => undefined,
settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: sidebarItems.map((item) => ({
...item,
-1
View File
@@ -61,7 +61,6 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -24,7 +24,6 @@ function plugin(input: {
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -9,8 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null }));
const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null }));
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient),
useHosts: () => [{ serverId: "host-1", label: "Local" }],
}));
-2
View File
@@ -13,7 +13,6 @@ import type {
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
PluginComposerPillContribution as SDKPluginComposerPillContribution,
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
@@ -49,7 +48,6 @@ export interface EvaluatedPlugin {
cleanup: PluginCleanup;
surfaces: PluginScreenContribution[];
settingsScreens: PluginSettingsScreenContribution[];
sidebarViews: PluginSidebarViewContribution<PluginText>[];
sidebarItems: Record<PluginSidebarSection, PluginSidebarItemContribution[]>;
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
@@ -21,7 +21,6 @@ function installed(): InstalledPlugin {
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -20,8 +20,7 @@ const runtime = vi.hoisted(() => ({
client: null as DaemonClient | null,
}));
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => runtime.client,
useHostRuntimeIsConnected: () => runtime.connected,
}));
@@ -1,4 +1,3 @@
import { SidebarViewPicker } from "@/plugins/sidebar-view";
import { useCallback, useMemo, type ComponentType, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -184,7 +183,6 @@ function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElemen
export function SidebarNavSection(): ReactElement {
return (
<>
<SidebarViewPicker />
<SidebarItemsCard section="header" />
<SidebarItemsCard section="footer" />
</>
-1
View File
@@ -1,6 +1,5 @@
declare module "turndown-plugin-gfm" {
import type TurndownService from "turndown";
export const tables: TurndownService.Plugin;
export const gfm: TurndownService.Plugin;
}
+1 -7
View File
@@ -6578,16 +6578,10 @@ export class DaemonClient {
// ============================================================================
observeTerminals(input: {
cwd?: string;
cwd: string;
workspaceId?: string;
signal?: AbortSignal;
}): OwnedSubscription<CorrelatedResponsePayload<"terminals_changed">> {
// COMPAT(terminalCollection): added in v0.16, remove after 2027-06.
if (
input.cwd === undefined &&
this.getLastServerInfoMessage()?.features?.terminalCollection !== true
)
throw new Error("Update the host to list terminals across workspaces.");
const { signal, ...query } = input;
return this.observe(
"terminals_changed",
-36
View File
@@ -1,4 +1,3 @@
import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels";
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
@@ -191,8 +190,6 @@ export interface PaseoWorkspaceHandle {
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
setPinned(pinned: boolean): Promise<{ pinnedAt: string | null }>;
setLabel(name: string, options: { assigned: boolean }): Promise<{ labels: string[] }>;
setBaseBranch(options: {
baseRef: string;
requestId?: string;
@@ -902,14 +899,6 @@ function createWorkspaceHandleFactory(
current: () => current,
refresh,
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
setPinned: (pinned) => daemonClient.setWorkspacePinned(id, pinned),
setLabel: (name, options) =>
assignWorkspaceLabel({
client: daemonClient,
workspaceId: id,
name,
assigned: options.assigned,
}),
setBaseBranch: (options) =>
daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }),
archive: async (requestId) => {
@@ -1299,28 +1288,3 @@ function createGeneratedClientId(): string {
: Math.random().toString(36).slice(2);
return `paseo-sdk-${randomId}`;
}
interface WorkspaceLabelAssignment {
client: DaemonClient;
workspaceId: string;
name: string;
assigned: boolean;
}
async function assignWorkspaceLabel(
input: WorkspaceLabelAssignment,
): Promise<{ labels: string[] }> {
input.client.ensureConnected();
// COMPAT(workspaceLabels): added in v0.5.0, remove gate after 2027-08-14.
if (input.client.getLastServerInfoMessage()?.features?.workspaceLabels !== true)
throw new Error("Update the host to assign workspace labels.");
const catalog = await input.client.listWorkspaceLabels();
const key = workspaceLabelKey(input.name);
const existing = catalog.labels.find((label) => workspaceLabelKey(label.name) === key);
const label = existing ?? { name: input.name, color: "violet" as const };
const result = await input.client.setWorkspaceLabel({
workspaceId: input.workspaceId,
label,
assigned: input.assigned,
});
return { labels: result.workspaceLabels };
}
+1 -112
View File
@@ -213,18 +213,8 @@ async function settleDesktopStartup(page) {
}
}
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
const server = createServer((request, response) => {
if (request.url === CSP_PAGE_PATH) {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; script-src 'self'",
});
response.end("<!doctype html><title>CSP page</title><h1>CSP page heading</h1>");
return;
}
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html>
@@ -256,67 +246,6 @@ async function closeServer(server) {
await new Promise((resolve) => server.close(resolve));
}
// browser_logs must report what the server answered: a 404 may not look like a 200.
async function verifyNetworkLogsReportStatus(client) {
const server = createServer((request, response) => {
response.writeHead(request.url === "/missing" ? 404 : 200, {
"content-type": "text/html; charset=utf-8",
});
response.end("<!doctype html><title>Status page</title><h1>Status page heading</h1>");
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
assert(address && typeof address !== "string", "Status page did not bind a TCP port");
let browserId = null;
try {
const created = await callBrowserTool(client, "browser_new_tab", {
url: `http://127.0.0.1:${address.port}/`,
});
browserId = created.browserId;
assert(
typeof browserId === "string",
"browser_new_tab returned no browserId for the status page",
);
await callBrowserTool(client, "browser_wait", {
browserId,
text: "Status page heading",
timeoutMs: 15_000,
});
const fetched = await callBrowserTool(client, "browser_evaluate", {
browserId,
function:
"async () => { const response = await fetch('/missing'); await response.text(); return response.status; }",
});
assert(
JSON.parse(fetched.resultJson) === 404,
`The status page did not answer 404: ${fetched.resultJson}`,
);
// The browser records a request's timing entry a moment after the response completes.
const deadline = Date.now() + 5_000;
let seen = [];
while (Date.now() < deadline) {
const logs = await callBrowserTool(client, "browser_logs", { browserId });
seen = logs.network.map((entry) => ({
pathname: new URL(entry.url).pathname,
status: entry.status ?? null,
}));
const reported = (pathname, status) =>
seen.some((entry) => entry.pathname === pathname && entry.status === status);
if (reported("/missing", 404) && reported("/", 200)) return;
await delay(100);
}
throw new Error(
`browser_logs did not report 404 for /missing and 200 for the page: ${JSON.stringify(seen)}`,
);
} finally {
if (browserId) await callBrowserTool(client, "browser_close_tab", { browserId });
await closeServer(server);
}
}
function mcpPayload(result, command) {
const payload = result.structuredContent;
assert(
@@ -361,42 +290,6 @@ async function waitForGuestActiveElement(client, browserId, elementId) {
return false;
}
// A page whose Content Security Policy omits 'unsafe-eval' refuses eval(); the tools must still work.
async function verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl) {
const created = await callBrowserTool(client, "browser_new_tab", {
url: new URL(CSP_PAGE_PATH, targetUrl).href,
});
const browserId = created.browserId;
assert(typeof browserId === "string", "browser_new_tab returned no browserId for the CSP page");
try {
await callBrowserTool(client, "browser_wait", {
browserId,
text: "CSP page heading",
timeoutMs: 15_000,
});
const title = await callBrowserTool(client, "browser_evaluate", {
browserId,
function: "() => document.title",
});
assert(
JSON.parse(title.resultJson) === "CSP page",
`browser_evaluate on a page without 'unsafe-eval' returned ${title.resultJson}`,
);
// The control: the page itself really does refuse eval().
const probe = await callBrowserTool(client, "browser_evaluate", {
browserId,
function:
'() => { try { (0, eval)("1"); return "eval allowed"; } catch (error) { return error.message; } }',
});
assert(
/Content Security Policy/.test(JSON.parse(probe.resultJson)),
`The CSP page did not refuse eval(): ${probe.resultJson}`,
);
} finally {
await callBrowserTool(client, "browser_close_tab", { browserId });
}
}
async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) {
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`),
@@ -782,8 +675,6 @@ async function runRegression({
document.body.appendChild(overlayRoot);
});
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
const browserId = created.browserId;
assert(typeof browserId === "string", "browser_new_tab returned no browserId");
@@ -1129,7 +1020,6 @@ async function runRegression({
JSON.parse(readyStateResult.resultJson) === "complete",
"Local browser document did not finish loading",
);
await verifyNetworkLogsReportStatus(client);
// Reproduce the report's mismatch: the guest is complete, but the pane's
// last loading signal says it is not ready.
await page.evaluate((id) => {
@@ -1216,7 +1106,6 @@ async function runRegression({
snapshot: "passed",
click: "passed",
localPageSelectors: "passed",
evaluateWithoutUnsafeEval: "passed",
};
}
@@ -1,5 +1,4 @@
import { resolve as resolvePath } from "node:path";
import { createContext, runInContext } from "node:vm";
import { describe, expect, test, vi } from "vitest";
import type {
@@ -435,54 +434,6 @@ function containsScript(tab: FakeTab, ...parts: string[]): boolean {
return tab.scripts.some((script) => parts.every((part) => script.includes(part)));
}
/**
* A browser whose tab runs the network script the host injects against `performance`, the way a
* page runs it, so the test sees what the script reads from Chromium's timing entries.
*/
function browserWithTimingEntries(entries: { resource: unknown[]; navigation: unknown[] }) {
const browser = new BrowserAutomationHarness();
const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab);
const page = createContext({
performance: {
getEntriesByType: (type: string) =>
type === "navigation" ? entries.navigation : entries.resource,
},
});
browser.tab.executeJavaScript = async (script: string) =>
script.includes("performance.getEntriesByType")
? runInContext(script, page)
: fakeAnswer(script);
return browser;
}
/**
* Runs the script the host injects into a page the way Chromium runs it on a page whose Content
* Security Policy omits 'unsafe-eval': injected code runs, but code in the page cannot turn a
* string into code (eval, new Function). Node's vm refuses string code generation the same way.
*/
function pageRunningInjectedScripts(input: {
allowEval: boolean;
globals?: Record<string, unknown>;
}) {
const page = createContext(
{ document: { title: "Fixture" }, ...input.globals },
{ codeGeneration: { strings: input.allowEval, wasm: input.allowEval } },
);
return (script: string): unknown => runInContext(script, page);
}
/** A browser whose tab runs the evaluate script in `page`; its other scripts keep the fake answers. */
function browserEvaluatingIn(page: (script: string) => unknown) {
const browser = new BrowserAutomationHarness();
const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab);
browser.tab.executeJavaScript = async (script: string) => {
if (!script.includes("__PASEO_BROWSER_EVALUATE__")) return fakeAnswer(script);
browser.tab.scripts.push(script);
return page(script);
};
return browser;
}
function requireSnapshotRefs(result: Awaited<ReturnType<BrowserAutomationHarness["snapshot"]>>) {
expect(result).toEqual({
requestId: "req-snapshot",
@@ -1455,72 +1406,6 @@ describe("executeAutomationCommand", () => {
});
});
test("logs reports the HTTP status the browser recorded, and none it did not", async () => {
const timing = (name: string, initiatorType: string, responseStatus?: number) => ({
name,
initiatorType,
startTime: 1,
duration: 2,
transferSize: 3,
...(responseStatus === undefined ? {} : { responseStatus }),
});
const browser = browserWithTimingEntries({
resource: [
timing("https://a.test/api/orders", "fetch", 404),
// Chromium reports 0 for a cross-origin response that sends no Timing-Allow-Origin.
timing("https://cdn.test/lib.js", "script", 0),
// A browser without the field.
timing("https://a.test/old.css", "css"),
],
navigation: [timing("https://a.test/form", "navigation", 200)],
});
const result = await browser.execute({ command: "logs", args: { browserId: BROWSER_A } });
expect(result).toEqual({
requestId: "req-logs",
ok: true,
result: {
command: "logs",
browserId: BROWSER_A,
console: [],
network: [
{
url: "https://a.test/api/orders",
type: "fetch",
status: 404,
startTime: 1,
duration: 2,
transferSize: 3,
},
{
url: "https://cdn.test/lib.js",
type: "script",
startTime: 1,
duration: 2,
transferSize: 3,
},
{
url: "https://a.test/old.css",
type: "css",
startTime: 1,
duration: 2,
transferSize: 3,
},
{
url: "https://a.test/form",
method: "GET",
type: "navigation",
status: 200,
startTime: 1,
duration: 2,
transferSize: 3,
},
],
},
});
});
test("evaluate returns primitive JSON from the page context", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.evaluateScriptResult = { ok: true, resultJson: "42" };
@@ -1673,99 +1558,6 @@ describe("executeAutomationCommand", () => {
expect(containsScript(browser.tab, "resultJson.slice(0, 79000)")).toBe(true);
});
describe.each([
{ page: "allows eval", allowEval: true },
{ page: "forbids eval (its Content Security Policy omits 'unsafe-eval')", allowEval: false },
])("evaluate on a page that $page", ({ allowEval }) => {
const evaluate = (browser: BrowserAutomationHarness, source: string) =>
browser.execute({
command: "evaluate",
args: { browserId: BROWSER_A, function: source },
});
test("runs the function and returns its result as JSON", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "() => document.title")).toEqual({
requestId: "req-evaluate",
ok: true,
result: {
command: "evaluate",
browserId: BROWSER_A,
resultJson: '"Fixture"',
truncated: false,
},
});
});
test("awaits an async function", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
const result = await evaluate(
browser,
"async () => { await Promise.resolve(); return 6 * 7; }",
);
expect(result).toMatchObject({ ok: true, result: { resultJson: "42" } });
});
test("resolves names against the page's globals, never the wrapper's own", async () => {
const globals = {
element: "page element",
args: "page args",
value: "page value",
resultJson: "page json",
userFunction: "page function",
__paseoEvaluate: "page wrapper",
readFunction: "page reader",
};
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval, globals }));
const result = await evaluate(
browser,
"() => [element, args, value, resultJson, userFunction, __paseoEvaluate, readFunction]",
);
expect(result).toMatchObject({
ok: true,
result: { resultJson: JSON.stringify(Object.values(globals)) },
});
});
test("reports what the function throws", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "() => { throw new Error('boom'); }")).toMatchObject({
ok: false,
error: { code: "browser_unknown_error", message: "boom" },
});
});
test("reports an expression that is not a function", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "document.title")).toMatchObject({
ok: false,
error: {
code: "browser_unknown_error",
message: "browser_evaluate input must evaluate to a function.",
},
});
});
test("reports a syntax error with the compiler's own message, without touching the page", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
const result = await evaluate(browser, "() => {");
expect(result).toMatchObject({
ok: false,
error: { code: "browser_unknown_error", message: expect.stringMatching(/^Unexpected /) },
});
expect(containsScript(browser.tab, "__PASEO_BROWSER_EVALUATE__")).toBe(false);
});
});
test("screenshot captures the painted viewport", async () => {
const browser = new BrowserAutomationHarness();
@@ -1,5 +1,4 @@
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
import { Script } from "node:vm";
import type {
BrowserAutomationCommand,
@@ -850,11 +849,6 @@ async function executeEvaluate(
elementExpression = expression;
}
const syntaxError = functionSyntaxError(functionSource);
if (syntaxError !== null) {
return fail(requestId, "browser_unknown_error", capEvaluateErrorMessage(syntaxError));
}
let rawResult: unknown;
try {
rawResult = await target.contents.executeJavaScript(
@@ -1556,36 +1550,14 @@ function capEvaluateErrorMessage(message: string): string {
: message.slice(0, MAX_EVALUATE_ERROR_MESSAGE_LENGTH);
}
/**
* The syntax error in the agent's function, or null. It is compiled here because a script that
* fails to compile in the page only reports "Script failed to execute", and a typo deserves the
* compiler's own message. Compiling runs nothing.
*/
function functionSyntaxError(functionSource: string): string | null {
try {
void new Script(`(${functionSource})`);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
/**
* The page script that runs the agent's function and returns its result as bounded JSON.
*
* The function goes into the script as code, not as a string for the page to eval(): a page whose
* Content Security Policy omits 'unsafe-eval' refuses eval() but still runs what the host injects.
* The function-producing thunk is an argument defined outside the wrapper, so its names resolve
* against the page's globals without capturing any wrapper parameters or locals.
*/
function buildEvaluateScript(
functionSource: string,
elementExpression: string | undefined,
): string {
return String.raw`(async (readFunction) => {
return String.raw`(async () => {
const __PASEO_BROWSER_EVALUATE__ = true;
try {
const userFunction = readFunction();
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
if (typeof userFunction !== 'function') {
throw new Error('browser_evaluate input must evaluate to a function.');
}
@@ -1610,7 +1582,7 @@ function buildEvaluateScript(
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
})(() => (${functionSource}))`;
})()`;
}
async function focusKeypressTarget(
@@ -1656,8 +1628,6 @@ const NETWORK_PERFORMANCE_SCRIPT = String.raw`(() => {
url: entry.name,
method: entry.initiatorType === 'navigation' ? 'GET' : undefined,
type: entry.initiatorType,
// 0 when the browser does not say (a cross-origin response without Timing-Allow-Origin).
status: typeof entry.responseStatus === 'number' && entry.responseStatus > 0 ? entry.responseStatus : undefined,
startTime: entry.startTime,
duration: entry.duration,
transferSize: typeof entry.transferSize === 'number' ? entry.transferSize : undefined,
-42
View File
@@ -388,45 +388,3 @@ describe("plugin subscription SDK boundary", () => {
).toEqual([]);
});
});
describe("sidebar data SDK boundary", () => {
it("accepts references and preserves string-id panel consumers", () => {
expect(
typeErrors(`
import { useWorkspace, useAgent, usePaseo, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client";
import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin";
function Panel({ workspace, agent }: { workspace: WorkspaceRef; agent: AgentRef }) {
const snapshot: PluginWorkspaceSnapshot | null = useWorkspace(workspace);
const title: string | null = useWorkspace("workspace", item => item.name);
const status = useAgent(agent, item => ({ status: item.status }), (a, b) => a?.status === b?.status);
const agentSnapshot = useAgent(agent);
const custom = useWorkspace(workspace, undefined, (a, b) => a?.name === b?.name);
const workspaces: readonly WorkspaceRef[] = useWorkspaces({ orderBy: "name" });
const groups: readonly Group<AgentRef>[] = useAgents({ groupBy: item => item.status });
const projects: readonly ProjectRef[] = useProjects({ hosts: [workspace.hostId] });
const terminals: readonly TerminalRef[] = useTerminals();
const paseo = usePaseo();
void paseo.workspaces.ref(workspace).setPinned(true);
void paseo.workspaces.ref(workspace).setLabel("Later", { assigned: true });
void paseo.workspaces.ref("old-id").setTitle("Title");
if (projects[0]) { paseo.projects.ref(projects[0]).open(); void paseo.projects.ref(projects[0]).createWorkspace(); }
if (terminals[0]) { paseo.terminals.ref(terminals[0]).focus(); void paseo.terminals.ref(terminals[0]).kill(); }
void paseo.agents.ref(agent);
if (projects[0]) useProject(projects[0], item => item.displayName);
if (terminals[0]) useTerminal(terminals[0]);
function View(props: PluginSidebarViewProps) {
props.navigation.openWorkspace(workspace);
props.navigation.openAgent(agent);
props.dismiss();
return null;
}
function contribute(client: PluginClientContext) {
client.addSidebarView({ id: "workspaces", title: "Workspaces", Component: View });
}
const query: Query<PluginWorkspaceSnapshot> = { hosts: [workspace.hostId], orderBy: ["name", "desc"] };
return null;
}
`),
).toEqual([]);
});
});
+35 -122
View File
@@ -1,30 +1,12 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { AgentRef, WorkspaceRef, ProjectRef, TerminalRef } from "../refs.js";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
} from "../contracts.js";
import { createCollectionQuery, type Query, type Group } from "./query.js";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
subscribe(listener: () => void, options?: { terminals?: boolean }): () => void;
getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null;
getAgent(id: string | AgentRef): PluginAgentSnapshot | null;
getProject(ref: ProjectRef): PluginProjectSnapshot | null;
getTerminal(ref: TerminalRef): PluginTerminalSnapshot | null;
getSnapshot(): PluginClientSnapshot;
}
export interface PluginClientSnapshot {
readonly workspaces: readonly PluginWorkspaceSnapshot[];
readonly agents: readonly PluginAgentSnapshot[];
readonly projects: readonly PluginProjectSnapshot[];
readonly terminals: readonly PluginTerminalSnapshot[];
readonly terminalErrors?: ReadonlyMap<string, Error>;
subscribe(listener: () => void): () => void;
getWorkspace(id: string): PluginWorkspaceSnapshot | null;
getAgent(id: string): PluginAgentSnapshot | null;
}
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
@@ -33,113 +15,44 @@ export function usePluginClientStateSource(): PluginClientStateSource | null {
return useContext(PluginClientStateContext);
}
/** Shared selection semantics for host surfaces and plugin entities. */
export function usePaseoSelector<Entity, Selection>(
source: { subscribe(listener: () => void): () => void },
read: () => Entity,
selector: (entity: Entity) => Selection,
equals: (left: Selection, right: Selection) => boolean = shallowEqual,
): Selection {
return useSyncExternalStoreWithSelector(source.subscribe, read, read, selector, equals);
}
type Equality<T> = { equals(left: T, right: T): boolean }["equals"];
function useStateSource(terminals: boolean) {
function usePluginEntity<Entity, Selection>(input: {
id: string;
read(source: PluginClientStateSource, id: string): Entity | null;
selector(entity: Entity): Selection;
}): Selection | null {
const source = usePluginClientStateSource();
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
return useMemo(
() =>
terminals
? {
...source,
subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }),
}
: source,
[source, terminals],
if (!source) throw new Error("Plugin state hooks must run inside a workspace panel");
return useSyncExternalStoreWithSelector(
source.subscribe,
() => input.read(source, input.id),
() => input.read(source, input.id),
(entity) => (entity ? input.selector(entity) : null),
shallowEqual,
);
}
function createEntityHook<Ref, Entity>(
read: (source: PluginClientStateSource, ref: Ref) => Entity | null,
terminals = false,
) {
function useEntity(
ref: Ref,
selector?: undefined,
equals?: Equality<Entity | null>,
): Entity | null;
function useEntity<Selection>(
ref: Ref,
selector: (entity: Entity) => Selection,
equals?: Equality<Selection | null>,
): Selection | null;
function useEntity<Selection>(
ref: Ref,
selector?: (entity: Entity) => Selection,
equals?: Equality<Entity | Selection | null>,
): Entity | Selection | null {
const source = useStateSource(terminals);
function select(entity: Entity | null): Entity | Selection | null {
return entity !== null && selector ? selector(entity) : entity;
}
const equality = equals ?? (selector ? shallowEqual : Object.is);
return usePaseoSelector(source, () => read(source, ref), select, equality);
}
return useEntity;
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: workspaceId,
read: (source, id) => source.getWorkspace(id),
selector,
});
}
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string) =>
source.getWorkspace(ref),
);
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref));
export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref));
export const useTerminal = createEntityHook(
(source, ref: TerminalRef) => source.getTerminal(ref),
true,
);
function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly hostId: string }>(
read: (source: PluginClientStateSource, query: Query<Snapshot>) => readonly Snapshot[],
terminals = false,
) {
function useCollection(
query: Query<Snapshot> & { groupBy: NonNullable<Query<Snapshot>["groupBy"]> },
): readonly Group<Snapshot["ref"]>[];
function useCollection(
query?: Query<Snapshot> & { groupBy?: undefined },
): readonly Snapshot["ref"][];
function useCollection(
query: Query<Snapshot>,
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[];
function useCollection(
query: Query<Snapshot> = {},
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[] {
const source = useStateSource(terminals);
const project = useMemo(() => createCollectionQuery<Snapshot>(), []);
return usePaseoSelector(
source,
() => read(source, query),
(snapshots) => project(snapshots, query),
Object.is,
);
}
return useCollection;
export function useAgent<Selection>(
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: agentId,
read: (source, id) => source.getAgent(id),
selector,
});
}
export const useWorkspaces = createCollectionHook((source) => source.getSnapshot().workspaces);
export const useAgents = createCollectionHook((source) => source.getSnapshot().agents);
export const useProjects = createCollectionHook((source) => source.getSnapshot().projects);
export const useTerminals = createCollectionHook<PluginTerminalSnapshot>((source, query) => {
const snapshot = source.getSnapshot();
for (const [hostId, error] of snapshot.terminalErrors ?? []) {
if (!query.hosts || query.hosts.includes(hostId)) throw error;
}
return snapshot.terminals;
}, true);
export function PluginClientStateProvider({
children,
source,
+2 -22
View File
@@ -1,7 +1,6 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
import type { PluginText } from "../text.js";
import type { ComponentType } from "react";
import type { PluginPaseoApi } from "./reference-api.js";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
@@ -97,24 +96,6 @@ export interface PluginSidebarItemContribution<Text extends PluginText = string>
Component: ComponentType<PluginSidebarItemProps>;
}
export interface PluginSidebarViewProps extends Pick<PluginHostProps, "theme" | "layout"> {
active: { workspace: WorkspaceRef | null; agent: AgentRef | null };
navigation: {
openWorkspace(ref: WorkspaceRef): void;
openAgent(ref: AgentRef): void;
openTerminal(ref: TerminalRef): void;
openProject(ref: ProjectRef): void;
};
dismiss(): void;
}
export interface PluginSidebarViewContribution<Text extends PluginText = string> {
id: string;
title: Text;
description?: Text;
Component: ComponentType<PluginSidebarViewProps>;
}
/**
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
* and color; buttons widen the props with their host and target context.
@@ -204,7 +185,6 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
playAudio(source: { base64: string; mimeType: string }): Promise<void>;
addSettingsScreen(contribution: PluginSettingsScreenContribution<PluginText>): PluginCleanup;
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
addSidebarView(contribution: PluginSidebarViewContribution<PluginText>): PluginCleanup;
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
@@ -328,7 +308,7 @@ export interface PluginTimelineRendererContribution<Schema extends ZodType = Zod
}
export interface PluginCommandCapabilities {
paseo: PluginPaseoApi;
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
+1 -10
View File
@@ -1,11 +1,6 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export {
PluginClientStateProvider,
usePaseoSelector,
type PluginClientStateSource,
type PluginClientSnapshot,
} from "./client-state.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
@@ -36,7 +31,3 @@ export {
isPluginMessage,
} from "./translations.js";
export type { PluginLocaleSource } from "./translations.js";
export { createEntityRefs } from "./refs.js";
export { createPluginReferenceApi } from "./reference-api.js";
+1 -17
View File
@@ -15,8 +15,6 @@ export type {
PluginOpenScreenInput,
PluginScreenLocation,
PluginSidebarItemProps,
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginIcon,
PluginPanelLocation,
@@ -72,16 +70,7 @@ export {
useFileUrl,
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export {
useAgent,
useWorkspace,
useProject,
useTerminal,
useWorkspaces,
useAgents,
useProjects,
useTerminals,
} from "./client-state.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
@@ -113,8 +102,3 @@ export type { PluginI18n } from "./translations.js";
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
export { useTabState } from "./tab.js";
export type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
export type { Query, Group, GroupKey } from "./query.js";
export type { PluginPaseoApi, PluginProjectHandle, PluginTerminalHandle } from "./reference-api.js";
+5 -11
View File
@@ -1,23 +1,17 @@
import type { PluginPaseoApi } from "./reference-api.js";
import type { PaseoApi } from "@getpaseo/client";
import { createContext, useContext, type ReactNode } from "react";
const PaseoApiContext = createContext<PluginPaseoApi | null>(null);
const PaseoApiContext = createContext<PaseoApi | null>(null);
export function usePaseoContextValue(): PluginPaseoApi | null {
export function usePaseoContextValue(): PaseoApi | null {
return useContext(PaseoApiContext);
}
export function PaseoApiProvider({
children,
paseo,
}: {
children: ReactNode;
paseo: PluginPaseoApi;
}) {
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
}
export function usePaseo(): PluginPaseoApi {
export function usePaseo(): PaseoApi {
const paseo = usePaseoContextValue();
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
return paseo;
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { createCollectionQuery, type GroupKey } from "./query.js";
const first = Object.freeze({ key: "a" });
const second = Object.freeze({ key: "b" });
const work: GroupKey = "work";
const snapshots = [
{ ref: first, hostId: "one", title: "A", status: "running", labels: [work, work] },
{ ref: second, hostId: "two", title: "B", status: "idle", labels: ["home"] },
];
describe("collection query structural sharing", () => {
it("preserves collection identity when a row changes without changing membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const before = project(snapshots);
const after = project([{ ...snapshots[0]!, status: "waiting" }, snapshots[1]!]);
expect(after).toBe(before);
expect(after).toEqual([first, second]);
});
it("shares unaffected groups and deduplicates membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const query = { groupBy: (item: (typeof snapshots)[number]) => item.labels };
const before = project(snapshots, query);
const after = project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query);
expect(after[1]).toBe(before[1]);
expect(before[0]).toEqual({ key: "work", title: null, color: null, items: [first] });
expect(after[0]).toEqual({ key: "new", title: null, color: null, items: [first] });
expect(project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query)).toBe(after);
});
it("filters hosts and predicates before sorting", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
expect(project(snapshots, { hosts: ["two"] })).toEqual([second]);
expect(project(snapshots, { where: (item) => item.status === "running" })).toEqual([first]);
expect(project(snapshots, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(project(snapshots, { orderBy: (a, b) => a.title.localeCompare(b.title) })).toEqual([
first,
second,
]);
});
it("keeps nulls last in either direction and metadata from the first group", () => {
const project = createCollectionQuery<{
ref: typeof first;
hostId: string;
title: string | null;
}>();
const items = [
{ ref: first, hostId: "one", title: null },
{ ref: second, hostId: "one", title: "Z" },
];
expect(project(items, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(
project(items, { groupBy: (item) => ({ key: "all", title: item.title ?? "First" }) }),
).toEqual([{ key: "all", title: "First", color: null, items: [first, second] }]);
});
});
-115
View File
@@ -1,115 +0,0 @@
interface GroupDescriptor {
readonly key: string;
readonly title?: string;
readonly color?: string;
}
export type GroupKey = string | GroupDescriptor;
export interface Query<Snapshot> {
readonly hosts?: readonly string[];
readonly where?: (item: Snapshot) => boolean;
readonly orderBy?:
| keyof Snapshot
| readonly [keyof Snapshot, "asc" | "desc"]
| ((a: Snapshot, b: Snapshot) => number);
readonly groupBy?: (item: Snapshot) => GroupKey | readonly GroupKey[];
}
export interface Group<Ref> {
readonly key: string;
readonly title: string | null;
readonly color: string | null;
readonly items: readonly Ref[];
}
function groupKeys(value: GroupKey | readonly GroupKey[]): readonly GroupKey[] {
if (typeof value === "string") return [value];
if ("key" in value) return [value];
return value;
}
interface Referenced {
readonly hostId: string;
readonly ref: unknown;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((item, index) => item === right[index]);
}
function compareValues(left: unknown, right: unknown, direction: number): number {
if (Object.is(left, right)) return 0;
if (left === null || left === undefined) return 1;
if (right === null || right === undefined) return -1;
if (typeof left === "number" && typeof right === "number") return (left - right) * direction;
return String(left).localeCompare(String(right)) * direction;
}
/** A hook owns one projection; sharing survives both source and query changes. */
export function createCollectionQuery<Snapshot extends Referenced>() {
type Ref = Snapshot["ref"];
let previousItems: readonly Ref[] = Object.freeze([]);
let previousGroups: readonly Group<Ref>[] = Object.freeze([]);
return function project(
snapshots: readonly Snapshot[],
query: Query<Snapshot> = {},
): readonly Ref[] | readonly Group<Ref>[] {
const items = snapshots.filter((item) => {
if (query.hosts && !query.hosts.includes(item.hostId)) return false;
return !query.where || query.where(item);
});
const order = query.orderBy;
if (typeof order === "function") items.sort(order);
else if (order !== undefined) {
const field = typeof order === "object" ? order[0] : order;
const direction = typeof order === "object" && order[1] === "desc" ? -1 : 1;
items.sort((a, b) => compareValues(a[field], b[field], direction));
}
if (!query.groupBy) {
const refs = items.map((item) => item.ref);
if (!sameSequence(previousItems, refs)) previousItems = Object.freeze(refs);
return previousItems;
}
const groups = new Map<
string,
{ key: string; title: string | null; color: string | null; items: Ref[] }
>();
for (const item of items) {
const grouping = query.groupBy(item);
const keys = groupKeys(grouping);
const seen = new Set<string>();
for (const value of keys) {
const descriptor = typeof value === "string" ? { key: value } : value;
if (seen.has(descriptor.key)) continue;
seen.add(descriptor.key);
let group = groups.get(descriptor.key);
if (!group) {
group = {
key: descriptor.key,
title: descriptor.title ?? null,
color: descriptor.color ?? null,
items: [],
};
groups.set(descriptor.key, group);
}
group.items.push(item.ref);
}
}
const oldGroups = new Map(previousGroups.map((group) => [group.key, group]));
const nextGroups = Array.from(groups.values(), (group) => {
const previous = oldGroups.get(group.key);
if (
previous &&
previous.title === group.title &&
previous.color === group.color &&
sameSequence(previous.items, group.items)
)
return previous;
return Object.freeze({ ...group, items: Object.freeze(group.items) });
});
if (!sameSequence(previousGroups, nextGroups)) previousGroups = Object.freeze(nextGroups);
return previousGroups;
};
}
@@ -1,88 +0,0 @@
import type {
PaseoApi,
PaseoAgent,
PaseoAgentHandle,
PaseoWorkspace,
PaseoWorkspaceHandle,
PaseoWorkspaceCreateOptions,
} from "@getpaseo/client";
import type { PaseoTerminal, PaseoTerminalHandle } from "@getpaseo/client";
import type { PluginProjectSnapshot } from "../contracts.js";
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
import type { PluginSidebarViewProps } from "./contracts.js";
export interface PluginProjectHandle {
readonly ref: ProjectRef;
open(): void;
createWorkspace(
input?: Omit<PaseoWorkspaceCreateOptions, "source"> & {
source?: PaseoWorkspaceCreateOptions["source"];
},
): Promise<PaseoWorkspaceHandle>;
}
export interface PluginTerminalHandle extends PaseoTerminalHandle {
focus(): void;
}
export interface PluginPaseoApi extends PaseoApi {
workspaces: Omit<PaseoApi["workspaces"], "ref"> & {
ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle;
};
agents: Omit<PaseoApi["agents"], "ref"> & {
ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle;
};
projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle };
terminals: Omit<PaseoApi["terminals"], "ref"> & {
ref(terminal: TerminalRef): PluginTerminalHandle;
ref(terminal: string | PaseoTerminal): PaseoTerminalHandle;
};
}
/** Host adapter; the installation retains its single API identity across every surface. */
export function createPluginReferenceApi(input: {
paseo: PaseoApi;
resolveHost(hostId: string): PaseoApi;
readProject(ref: ProjectRef): PluginProjectSnapshot | null;
navigation: PluginSidebarViewProps["navigation"];
}): PluginPaseoApi {
const { paseo } = input;
const workspaceRef = paseo.workspaces.ref;
const agentRef = paseo.agents.ref;
const terminalRef = paseo.terminals.ref;
function refWorkspace(workspace: string | PaseoWorkspace | WorkspaceRef) {
if (typeof workspace !== "string" && "hostId" in workspace)
return input.resolveHost(workspace.hostId).workspaces.ref(workspace.id);
return workspaceRef(workspace);
}
function refAgent(agent: string | PaseoAgent | AgentRef) {
if (typeof agent !== "string" && "hostId" in agent)
return input.resolveHost(agent.hostId).agents.ref(agent.id);
return agentRef(agent);
}
function terminal(ref: TerminalRef): PluginTerminalHandle;
function terminal(ref: string | PaseoTerminal): PaseoTerminalHandle;
function terminal(ref: TerminalRef | string | PaseoTerminal): PaseoTerminalHandle {
if (typeof ref !== "string" && "hostId" in ref) {
const handle = input.resolveHost(ref.hostId).terminals.ref(ref.id);
return Object.assign(handle, { focus: () => input.navigation.openTerminal(ref) });
}
return terminalRef(ref);
}
function project(ref: ProjectRef): PluginProjectHandle {
return {
ref,
open: () => input.navigation.openProject(ref),
async createWorkspace(options = {}) {
const snapshot = input.readProject(ref);
if (!snapshot) throw new Error("Project is no longer available");
const source = options.source ?? { kind: "directory", path: snapshot.rootPath };
return input.resolveHost(ref.hostId).workspaces.create({ ...options, source });
},
};
}
return Object.assign(paseo, {
workspaces: Object.assign(paseo.workspaces, { ref: refWorkspace }),
agents: Object.assign(paseo.agents, { ref: refAgent }),
projects: Object.assign(paseo.projects, { ref: project }),
terminals: Object.assign(paseo.terminals, { ref: terminal }),
});
}
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { createEntityRefs } from "./refs.js";
describe("session entity references", () => {
it("interns frozen references separately by kind, host and entity", () => {
const refs = createEntityRefs();
for (const reference of [refs.workspace, refs.agent, refs.project, refs.terminal]) {
const first = reference("host", "entity");
expect(reference("host", "entity")).toBe(first);
expect(Object.isFrozen(first)).toBe(true);
expect(reference("other", "entity")).not.toBe(first);
expect(reference("host", "other")).not.toBe(first);
}
expect(refs.workspace("host", "entity")).not.toBe(refs.agent("host", "entity"));
});
it("keeps opaque ids unambiguous and interning scoped to one app session", () => {
const refs = createEntityRefs();
expect(refs.workspace("a:b", "c").key).not.toBe(refs.workspace("a", "b:c").key);
expect(createEntityRefs().workspace("host", "entity")).not.toBe(
refs.workspace("host", "entity"),
);
});
});
-26
View File
@@ -1,26 +0,0 @@
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
/** Owned by the app session, shared by every plugin installation. */
export function createEntityRefs() {
function intern<Kind extends string>(kind: Kind) {
const references = new Map<
string,
Readonly<{ kind: Kind; key: string; hostId: string; id: string }>
>();
return function reference(hostId: string, id: string) {
const key = JSON.stringify([hostId, id]);
let ref = references.get(key);
if (!ref) {
ref = Object.freeze({ kind, key, hostId, id });
references.set(key, ref);
}
return ref;
};
}
return {
workspace: intern("workspace") satisfies (hostId: string, id: string) => WorkspaceRef,
agent: intern("agent") satisfies (hostId: string, id: string) => AgentRef,
project: intern("project") satisfies (hostId: string, id: string) => ProjectRef,
terminal: intern("terminal") satisfies (hostId: string, id: string) => TerminalRef,
};
}
-40
View File
@@ -1,4 +1,3 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "./refs.js";
import type { PluginText } from "./text.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginSerializableIcon } from "./icons.js";
@@ -20,22 +19,7 @@ export interface PluginTheme {
};
}
export type WorkspaceIndicator = "needs_input" | "failed" | "running" | "attention" | "done";
export interface PluginWorkspaceSnapshot {
readonly ref: WorkspaceRef;
readonly hostId: string;
readonly project: ProjectRef;
readonly pinnedAt: string | null;
readonly labels: readonly string[];
readonly background: boolean;
readonly branch: string | null;
/** Null when an older host has not supplied the workspace creation time. */
readonly createdAt: string | null;
readonly lastPromptAt: string | null;
readonly parentAgent: AgentRef | null;
readonly indicator: WorkspaceIndicator;
readonly indicatorLabel: string | null;
readonly id: string;
readonly projectId: string;
readonly projectDisplayName: string;
@@ -45,7 +29,6 @@ export interface PluginWorkspaceSnapshot {
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
readonly name: string;
readonly title: string | null;
/** Raw daemon status; prefer indicator for presentation. */
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
readonly statusEnteredAt: string | null;
readonly archivingAt: string | null;
@@ -53,10 +36,6 @@ export interface PluginWorkspaceSnapshot {
}
export interface PluginAgentSnapshot {
readonly ref: AgentRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly parentAgent: AgentRef | null;
readonly id: string;
readonly workspaceId: string;
readonly provider: string;
@@ -117,22 +96,3 @@ export interface PluginTimelineTransformResult {
}
export type PluginCleanup = () => void | Promise<void>;
export interface PluginProjectSnapshot {
readonly ref: ProjectRef;
readonly hostId: string;
readonly displayName: string;
readonly rootPath: string;
readonly kind: PluginWorkspaceSnapshot["projectKind"];
readonly iconRevision: string | null;
}
export interface PluginTerminalSnapshot {
readonly ref: TerminalRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly title: string;
readonly isScript: boolean;
/** Null for terminals owned by workers started before timestamps were tracked. */
readonly createdAt: string | null;
}
-3
View File
@@ -3,9 +3,6 @@ export type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
WorkspaceIndicator,
PluginThemeColors,
PluginThemeContribution,
PluginAttachmentSourceContribution,
-11
View File
@@ -1,11 +0,0 @@
interface EntityRef<Kind extends string> {
readonly kind: Kind;
readonly key: string;
readonly hostId: string;
readonly id: string;
}
export interface WorkspaceRef extends EntityRef<"workspace"> {}
export interface AgentRef extends EntityRef<"agent"> {}
export interface ProjectRef extends EntityRef<"project"> {}
export interface TerminalRef extends EntityRef<"terminal"> {}
+3 -4
View File
@@ -4,10 +4,10 @@ import { defineRpc } from "./rpc.js";
export interface SettingsDefinition<Schema extends ZodType = ZodType> {
id: string;
scope: "host" | "device";
scope: "host";
version: number;
schema: Schema;
/** Convert a previous stored version to the current schema. Runs in the selected scope. */
/** Convert a previous stored version to the current schema. Runs on the host. */
migrate?: (values: unknown, fromVersion: number) => unknown | Promise<unknown>;
}
@@ -15,8 +15,7 @@ export function defineSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsDefinition<Schema> {
if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID");
if (definition.scope !== "host" && definition.scope !== "device")
throw new Error("Invalid settings scope");
if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported");
if (!Number.isSafeInteger(definition.version) || definition.version < 1)
throw new Error("Invalid settings version");
return definition;
-40
View File
@@ -571,43 +571,3 @@ test.each(["all", "base", "uncommitted"] as const)(
expect(parsed.attachments).toEqual([attachment]);
},
);
describe("terminal collection protocol compatibility", () => {
test("accepts released directory observations and their snapshots", () => {
expect(
SessionInboundMessageSchema.parse({
type: "subscribe_terminals_request",
cwd: "/repo",
requestId: "old",
}),
).toMatchObject({ cwd: "/repo" });
expect(
SessionOutboundMessageSchema.parse({
type: "terminals_changed",
payload: { cwd: "/repo", terminals: [{ id: "terminal", name: "Shell" }] },
}),
).toMatchObject({ payload: { cwd: "/repo" } });
});
test("accepts capability-gated host observations and additive metadata", () => {
expect(
SessionInboundMessageSchema.parse({ type: "subscribe_terminals_request", requestId: "host" }),
).toMatchObject({ requestId: "host" });
expect(
SessionOutboundMessageSchema.parse({
type: "terminals_changed",
payload: {
terminals: [
{
id: "terminal",
name: "Shell",
cwd: "/repo",
workspaceId: "workspace",
createdAt: null,
isScript: true,
},
],
},
}),
).toMatchObject({ payload: { terminals: [{ isScript: true, createdAt: null }] } });
});
});
+4 -7
View File
@@ -3093,13 +3093,13 @@ export const ListTerminalsRequestSchema = z.object({
export const SubscribeTerminalsRequestSchema = z.object({
type: z.literal("subscribe_terminals_request"),
requestId: z.string().optional(),
cwd: z.string().optional(),
cwd: z.string(),
workspaceId: z.string().optional(),
});
export const UnsubscribeTerminalsRequestSchema = z.object({
type: z.literal("unsubscribe_terminals_request"),
cwd: z.string().optional(),
cwd: z.string(),
workspaceId: z.string().optional(),
});
@@ -3770,7 +3770,6 @@ export const ServerInfoStatusPayloadSchema = z
directorySync: z.boolean().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
workspaceLabels: z.boolean().optional(),
terminalCollection: z.boolean().optional(),
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
workspaceBaseBranch: z.boolean().optional(),
// COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02.
@@ -6685,8 +6684,6 @@ export const ListCommandsResponseSchema = z.object({
// ============================================================================
const TerminalInfoSchema = z.object({
createdAt: z.string().nullable().optional(),
isScript: z.boolean().optional(),
id: z.string(),
name: z.string(),
cwd: z.string(),
@@ -6750,8 +6747,8 @@ export const TerminalsChangedSchema = z.object({
subscriptionId: z.string().optional(),
requestId: z.string().optional(),
workspaceId: z.string().optional(),
cwd: z.string().optional(),
terminals: z.array(TerminalInfoSchema.partial({ cwd: true })),
cwd: z.string(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
}),
});
@@ -3,11 +3,6 @@
// and tear down its own live subscription without disturbing the other. An
// absent workspaceId (old clients) keys to the cwd on its own. The `::`
// separator cannot collide with a workspace id, which is always `wks_<hex>`.
export function terminalSubscriptionKey(
cwd: string | undefined,
workspaceId: string | undefined,
): string {
if (cwd === undefined)
return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`;
export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string {
return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`;
}
@@ -700,71 +700,6 @@ test("stops an OpenCode execution already active when its conversation is reopen
}
});
test("a completed OpenCode model switch does not depend on another session read", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-model-"));
const harness = new V2Harness();
harness.info.location.directory = workdir;
harness.info.model = { providerID: "provider", id: "source" };
let observationUnavailable = false;
harness.api.model.list = async () => ({
location: { directory: workdir },
data: [
{
providerID: "provider",
id: "target",
modelID: "target",
name: "Target",
capabilities: { tools: true, input: ["text"], output: ["text"] },
enabled: true,
variants: [],
time: { released: 1 },
cost: [],
status: "active",
limit: { context: 200000, output: 10000 },
},
],
});
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
observationUnavailable = true;
};
harness.api.session.get = async () => {
if (observationUnavailable) throw new Error("Transport");
return harness.info;
};
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
const manager = new AgentManager({ clients: { opencode: client }, registry: storage, logger });
const agent = await manager.resumeAgentFromPersistence({
provider: "opencode",
sessionId: "session",
nativeHandle: "session",
metadata: { cwd: workdir },
});
try {
await manager.setAgentModel(agent.id, "provider/target");
await manager.flush();
expect(harness.info.model?.id).toBe("target");
expect(manager.getAgent(agent.id)?.config.model).toBe("provider/target");
expect(manager.getAgent(agent.id)?.runtimeInfo?.model).toBe("provider/target");
const stored = await storage.get(agent.id);
expect(stored?.config?.model).toBe("provider/target");
expect(stored?.runtimeInfo?.model).toBe("provider/target");
expect(stored?.persistence?.metadata?.model).toBe("provider/target");
observationUnavailable = false;
for await (const _event of manager.streamAgent(agent.id, "continue after switching")) {
// A successful mutation leaves a usable session.
}
expect(harness.prompts).toEqual(["continue after switching"]);
} finally {
observationUnavailable = false;
await manager.closeAgent(agent.id);
await manager.flush();
await client.shutdown();
rmSync(workdir, { recursive: true, force: true });
}
});
test("uses an injected timeline store without making it a production requirement", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-"));
const store = new RecordingTimelineStore();
@@ -4764,51 +4699,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
});
});
test("importProviderSession preserves the native model instead of applying the new-session default", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-model-"));
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const nativeModel = "native-selected-model";
class ImportClient extends TestAgentClient {
importConfig: AgentSessionConfig | undefined;
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
this.importConfig = context.config;
// A resumable provider applies explicit selections but otherwise retains its model.
const session = new TestAgentSession({
...context.config,
model: context.config.model ?? nativeModel,
});
return {
session,
config: context.storedConfig,
persistence: { provider: this.provider, sessionId: input.providerHandleId },
timeline: [],
};
}
}
const client = new ImportClient();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const imported = await manager.importProviderSession({
provider: "codex",
providerHandleId: "native-session",
cwd: workdir,
workspaceId: "import-model",
});
await manager.flush();
expect(imported.runtimeInfo?.model).toBe(nativeModel);
expect(imported.config.model).toBe(nativeModel);
expect(client.importConfig?.model).toBeUndefined();
expect((await storage.get(imported.id))?.config.model).toBe(nativeModel);
await manager.reloadAgentSession(imported.id);
expect(client.resumeOverrides.at(-1)?.model).toBe(nativeModel);
await manager.getAgent(imported.id)?.session?.close();
});
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
const storagePath = join(workdir, "agents");
@@ -5480,9 +5370,6 @@ test("model changes persist the resolved thinking and fresh provider handle befo
thinkingOptionId: null,
});
}
override async getRuntimeInfo() {
return { ...(await super.getRuntimeInfo()), ...this.selection };
}
describePersistence() {
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
}
@@ -5565,63 +5452,6 @@ test("later explicit config mutations win over events emitted by earlier mutatio
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
});
test.each([
{ requested: "unavailable", effective: "source" },
{ requested: "alias", effective: "canonical" },
{ requested: null, effective: null },
])(
"setAgentModel stores effective selections and preserves resets for $requested",
async ({ requested, effective }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-effective-model-"));
class SelectingSession extends TestAgentSession {
private model: string | null = "source";
async setModel(model: string | null) {
if (model && model !== "unavailable") this.model = model === "alias" ? "canonical" : model;
return { model: this.model };
}
override async getRuntimeInfo() {
return { ...(await super.getRuntimeInfo()), model: this.model };
}
}
class SelectingClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new SelectingSession(config);
}
}
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new SelectingClient() },
registry: storage,
logger,
});
try {
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "source" },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentModel(snapshot.id, requested);
await manager.flush();
expect(manager.getAgent(snapshot.id)?.config.model).toBe(effective ?? undefined);
expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe(effective);
const persisted = await storage.get(snapshot.id);
expect(persisted?.config?.model).toBe(effective ?? undefined);
expect(persisted?.runtimeInfo?.model).toBe(effective);
await manager.setAgentModel(snapshot.id, "target");
expect(manager.getAgent(snapshot.id)?.config.model).toBe("target");
expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe("target");
} finally {
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
}
},
);
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
class ClampingSession extends TestAgentSession {
@@ -1679,7 +1679,6 @@ export class AgentManager {
cwd: input.cwd,
},
resolvedAgentId,
{ resolveDefaultModel: false },
);
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
@@ -1706,13 +1705,8 @@ export class AgentManager {
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
const runtimeInfo = await imported.session.getRuntimeInfo();
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer({
...imported.config,
model: runtimeInfo.model ?? imported.config.model,
}),
{ resolveDefaultModel: false },
stripInternalPaseoMcpServer(imported.config),
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
@@ -2334,16 +2328,14 @@ export class AgentManager {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
const selection = await agent.session.setModel?.(normalizedModelId);
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
}
await this.drainSessionEvents(agentId);
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
agent.config.model = effectiveModelId ?? undefined;
agent.config.model = normalizedModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
@@ -5731,12 +5723,11 @@ export class AgentManager {
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
options: NormalizeConfigOptions = {},
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
env: options.env,
purpose: options.purpose,
resolveDefaultModel: options.resolveDefaultModel,
});
const paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
@@ -720,9 +720,7 @@ export interface AgentSession {
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
/** A returned model is the effective selection committed by this mutation.
* Returning void means the requested selection was accepted. No status read is required. */
setModel?(modelId: string | null): Promise<void | { model: string | null }>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
@@ -1400,10 +1400,9 @@ class PluginAgentSession implements AgentSession {
}));
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ model: modelId });
return { model: this.bridge.config.model ?? null };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -520,7 +520,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
setSessionConfigOption.mockClear();
await expect(session.setModel("new-provider-model")).resolves.toEqual({ model: "sonnet" });
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: "new-provider-model" },
expect.stringContaining("is not a valid claude-acp model config option"),
@@ -2281,17 +2281,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (!modelId) {
this.currentModel = null;
return { model: this.currentModel };
return;
}
await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) });
return { model: this.currentModel };
}
private resolveModelSelection(modelId: string): ACPModelSelection {
@@ -1,66 +0,0 @@
import { describe, expect, test } from "vitest";
import { ACPAgentClient } from "./acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
// A mode request deterministically releases replay through a controlled stdio peer.
// Post-result cases deliberately violate the protocol completion boundary.
const peer = String.raw`
import { createInterface } from 'node:readline';
const beforeResult = process.argv[1] === 'before';
const send = (value) => process.stdout.write(JSON.stringify({jsonrpc:'2.0', ...value})+'\n');
const update = (kind, id, text) => send({method:'session/update', params:{sessionId:'saved',update:{sessionUpdate:kind,messageId:id,content:{type:'text',text}}}});
const tail = () => {
update('agent_message_chunk','reply-1','first reply');
update('user_message_chunk','user-2','follow up');
update('agent_message_chunk','reply-2','second reply');
};
for await (const line of createInterface({input:process.stdin})) {
const request = JSON.parse(line);
if (request.method === 'initialize') {
send({id:request.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}});
} else if (request.method === 'session/load') {
update('user_message_chunk','user-1','first prompt');
if (beforeResult) tail();
send({id:request.id,result:{modes:{currentModeId:'default',availableModes:[{id:'default',name:'Default'}]}}});
} else if (request.method === 'session/set_mode') {
if (!beforeResult) tail();
send({id:request.id,result:{}});
} else if (request.id !== undefined) {
send({id:request.id,result:{}});
}
}
`;
describe("ACP persisted history through a real stdio peer", () => {
for (const order of ["before", "after", "during-drain"] as const) {
test(`retains all turns with ${order} replay`, async () => {
const client = new ACPAgentClient({
provider: "acp",
logger: createTestLogger(),
defaultCommand: [process.execPath, "--input-type=module", "-e", peer, order],
});
const session = await client.resumeSession(
{ provider: "acp", sessionId: "saved" },
{ cwd: process.cwd() },
);
try {
const collect = async () => {
const events: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) events.push(event);
return events;
};
const pendingHistory = order === "during-drain" ? collect() : undefined;
await session.setMode("default");
const history = await (pendingHistory ?? collect());
expect(
history.flatMap((event) =>
event.type === "timeline" && "text" in event.item ? [event.item.text] : [],
),
).toEqual(["first prompt", "first reply", "follow up", "second reply"]);
} finally {
await session.close();
}
});
}
});
@@ -2782,7 +2782,7 @@ class ClaudeAgentSession implements AgentSession {
this.currentMode = normalized;
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
const activeQuery = await this.ensureQuery();
@@ -2800,7 +2800,6 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
// Model change affects persistence metadata, so invalidate cached handle.
this.persistence = null;
return { model: this.lastOptionsModel ?? this.config.model ?? null };
}
private reconcileThinkingOptionForModel(modelId: string | null): void {
@@ -1379,42 +1379,6 @@ describe("Codex app-server provider", () => {
}
});
test.each([undefined, "configured-model"])(
"import resolves model %s before the first collaboration turn",
async (configuredModel) => {
const appServer = createFakeCodexAppServer({
"config/read": () => ({
config: { model: configuredModel, model_reasoning_effort: "low" },
}),
"collaborationMode/list": () => ({
data: [{ name: "Code", mode: "default", model: null, reasoning_effort: null }],
}),
});
const provider = createProviderWithFakeAppServer(appServer);
const config = createConfig({ model: undefined, thinkingOptionId: undefined });
const imported = await provider.importSession(
{ providerHandleId: "native-thread", cwd: config.cwd! },
{ config, storedConfig: config },
);
try {
await imported.session.startTurn("first follow-up after import");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
threadId: "native-thread",
model: configuredModel ?? "gpt-5.4",
effort: "low",
collaborationMode: {
mode: "default",
settings: { model: configuredModel ?? "gpt-5.4", reasoning_effort: "low" },
},
});
appServer.assertNoErrors();
} finally {
await imported.session.close();
}
},
);
test("provider persistence leaves options owned by the stored agent config", async () => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
@@ -5554,7 +5554,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.config.model = model;
this.reconcileServiceTier();
this.config.thinkingOptionId = thinkingOptionId;
this.refreshResolvedCollaborationMode();
return model;
}
@@ -1302,10 +1302,10 @@ export class OmpAgentSession implements AgentSession {
return null;
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return { model: modelToId(this.state.model) };
return;
}
if (!parsedReference.provider) {
throw new Error(`OMP model id must include a provider: ${modelId}`);
@@ -1314,7 +1314,6 @@ export class OmpAgentSession implements AgentSession {
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
this.config.model = `${model.provider}/${model.id}`;
await this.refreshState();
return { model: modelToId(this.state.model) };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -367,7 +367,6 @@ export class OpenCodeV2Session implements AgentSession {
thinkingOptionId: variant ?? null,
},
});
return { model: `${selected.providerID}/${selected.id}` };
}
async setThinkingOption(variant: string | null) {
@@ -1704,10 +1704,10 @@ export class PiRpcAgentSession implements AgentSession {
return null;
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return { model: modelToId(this.state.model) };
return;
}
if (!parsedReference.provider) {
throw new Error(`Pi model id must include a provider: ${modelId}`);
@@ -1716,7 +1716,6 @@ export class PiRpcAgentSession implements AgentSession {
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
await this.refreshState();
this.config.model = `${model.provider}/${model.id}`;
return { model: modelToId(this.state.model) };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {

Some files were not shown because too many files have changed in this diff Show More