mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bc8c00ea6 |
@@ -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 ({
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
|
||||
@@ -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: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }],
|
||||
}));
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
declare module "turndown-plugin-gfm" {
|
||||
import type TurndownService from "turndown";
|
||||
|
||||
export const tables: TurndownService.Plugin;
|
||||
export const gfm: TurndownService.Plugin;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] }]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,9 +3,6 @@ export type {
|
||||
PluginTheme,
|
||||
PluginWorkspaceSnapshot,
|
||||
PluginAgentSnapshot,
|
||||
PluginProjectSnapshot,
|
||||
PluginTerminalSnapshot,
|
||||
WorkspaceIndicator,
|
||||
PluginThemeColors,
|
||||
PluginThemeContribution,
|
||||
PluginAttachmentSourceContribution,
|
||||
|
||||
@@ -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"> {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }] } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user