mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5957a395da | ||
|
|
940a2cd1cd | ||
|
|
ef7de3d066 | ||
|
|
b7b38412a0 | ||
|
|
a48bc3aa6f | ||
|
|
28c01ea1ce | ||
|
|
a449271d57 | ||
|
|
aac228e0a8 | ||
|
|
0e40022519 | ||
|
|
751dc82e6c | ||
|
|
daf26892d3 | ||
|
|
c5cf6cbb4c | ||
|
|
78a089a14e | ||
|
|
ec3ae09926 | ||
|
|
9f68af0274 | ||
|
|
79f2db8d56 |
@@ -570,6 +570,13 @@ 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
|
||||
|
||||
@@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
|
||||
@@ -198,3 +198,81 @@ test.describe("Turn end", () => {
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
});
|
||||
|
||||
async function expectTimingInsideFooter(page: Page): Promise<void> {
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
|
||||
const labelBounds = element.parentElement!.getBoundingClientRect();
|
||||
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const lines: boolean[] = [];
|
||||
while (walker.nextNode()) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
for (const rect of range.getClientRects()) {
|
||||
lines.push(
|
||||
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
|
||||
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
|
||||
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
|
||||
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
expect(paintedLines.length).toBeGreaterThan(0);
|
||||
expect(paintedLines.every(Boolean)).toBe(true);
|
||||
}
|
||||
|
||||
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
|
||||
const footer = page.getByTestId("turn-footer").last();
|
||||
await footer.evaluate((element, availableWidth) => {
|
||||
element.style.width = `${availableWidth}px`;
|
||||
}, width);
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
await expect(label).toBeVisible();
|
||||
await expectTimingInsideFooter(page);
|
||||
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
|
||||
const before = await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
);
|
||||
const footerBefore = await footer.boundingBox();
|
||||
const labelBefore = await label.boundingBox();
|
||||
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
|
||||
expect(await label.boundingBox()).toMatchObject({
|
||||
x: labelBefore!.x,
|
||||
width: labelBefore!.width,
|
||||
height: labelBefore!.height,
|
||||
});
|
||||
expect(
|
||||
await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
),
|
||||
).toEqual(before);
|
||||
expect(await footer.boundingBox()).toMatchObject({
|
||||
x: footerBefore!.x,
|
||||
y: footerBefore!.y,
|
||||
width: footerBefore!.width,
|
||||
});
|
||||
await expectTimingInsideFooter(page);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
|
||||
}
|
||||
|
||||
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectFooterWithinWidth(page, 140);
|
||||
await expectFooterWithinWidth(page, 150);
|
||||
await expectFooterWithinWidth(page, 300);
|
||||
await expectFooterWithinWidth(page, 880);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
pasteRichTextIntoComposer,
|
||||
fillComposerDraft,
|
||||
copyLatestTurnIntoComposer,
|
||||
selectComposerDraftRange,
|
||||
undoComposerEdit,
|
||||
redoComposerEdit,
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
@@ -28,6 +34,7 @@ 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,
|
||||
@@ -55,7 +62,9 @@ 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)
|
||||
@@ -124,11 +133,15 @@ 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);
|
||||
@@ -136,8 +149,62 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await typeIntoFocusedComposer(page, "Second message");
|
||||
await expectComposerDraft(page, "Second message");
|
||||
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);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -168,7 +235,11 @@ 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({
|
||||
@@ -192,7 +263,9 @@ 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,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
|
||||
for (;;) {
|
||||
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
|
||||
if (!paste) break;
|
||||
const pasted = JSON.stringify(paste[1]).replace(
|
||||
/\s/g,
|
||||
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
|
||||
);
|
||||
process.stdout.write("PASTED:" + pasted + "\r\n");
|
||||
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
|
||||
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
|
||||
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
|
||||
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
|
||||
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
// Every bracketed paste body the receiver consumed, in order and joined: what a
|
||||
// shell would read as one command line.
|
||||
async function pastedText(terminalId: string): Promise<string> {
|
||||
const output = await terminalOutput(terminalId);
|
||||
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
|
||||
.map(([, json]) => JSON.parse(json) as string)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Exactly one ASCII space after each quoted path and nothing else between them,
|
||||
// so the paths stay separate shell words.
|
||||
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
|
||||
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
|
||||
}
|
||||
|
||||
test("pastes images from two panes into their own terminals as uploaded host files", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
|
||||
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
|
||||
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
|
||||
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
|
||||
try {
|
||||
await dropFiles(page, [first]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
|
||||
await dropFiles(page, [second]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
|
||||
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ 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,7 +31,9 @@ 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> {
|
||||
@@ -156,7 +158,9 @@ 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(
|
||||
@@ -218,18 +222,24 @@ 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. */
|
||||
@@ -283,7 +293,10 @@ 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);
|
||||
@@ -347,7 +360,11 @@ 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);
|
||||
@@ -505,7 +522,10 @@ 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([]);
|
||||
@@ -516,7 +536,10 @@ 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;
|
||||
@@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint(
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
|
||||
observer.observe(tab, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-selected"],
|
||||
});
|
||||
shortcut("BracketLeft");
|
||||
});
|
||||
});
|
||||
@@ -566,3 +592,44 @@ 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,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "createTerminal"
|
||||
| "killTerminal"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
| "setWorkspaceTitle"
|
||||
| "setWorkspaceLabel"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
alignSelf: "stretch",
|
||||
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
|
||||
},
|
||||
turnFooterContent: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SidebarViewSlot } from "@/plugins/sidebar-view";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
@@ -642,31 +643,33 @@ function MobileSidebar({
|
||||
)}
|
||||
<SidebarSeparator />
|
||||
|
||||
{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 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>
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
@@ -838,31 +841,33 @@ function DesktopSidebar({
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
|
||||
{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 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>
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
|
||||
@@ -557,6 +557,15 @@ interface AssistantTurnFooterProps {
|
||||
|
||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
height: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
@@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
marginTop: 0,
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
labelWrapper: {
|
||||
position: "relative",
|
||||
labelButton: {
|
||||
flex: 1,
|
||||
minHeight: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minWidth: 0,
|
||||
},
|
||||
labelSizer: {
|
||||
label: {
|
||||
width: "100%",
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
overlappingLabel: {
|
||||
marginLeft: "-100%",
|
||||
},
|
||||
hiddenLabel: {
|
||||
opacity: 0,
|
||||
},
|
||||
labelOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
}));
|
||||
|
||||
const TIMESTAMP_REVEAL_MS = 3000;
|
||||
@@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{getContent || canFork ? (
|
||||
<View style={assistantTurnFooterStylesheet.actions}>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{primaryLabel ? (
|
||||
<Pressable
|
||||
style={assistantTurnFooterStylesheet.labelButton}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
accessibilityRole={canSwap ? "button" : undefined}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||||
>
|
||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||
{/* Sizer reserves space for whichever label is longer so the
|
||||
container width is stable across hover transitions. */}
|
||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||||
{/* Both labels participate in layout at the available width. Overlapping their
|
||||
columns reserves the taller wrapped height without measuring text or characters. */}
|
||||
<Text
|
||||
testID={!showTimestamp ? "turn-footer-timing-label" : undefined}
|
||||
aria-hidden={showTimestamp}
|
||||
style={[
|
||||
assistantTurnFooterStylesheet.label,
|
||||
showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
|
||||
]}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Text>
|
||||
{canSwap ? (
|
||||
<Text
|
||||
testID={showTimestamp ? "turn-footer-timing-label" : undefined}
|
||||
aria-hidden={!showTimestamp}
|
||||
style={[
|
||||
assistantTurnFooterStylesheet.label,
|
||||
assistantTurnFooterStylesheet.overlappingLabel,
|
||||
!showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
|
||||
]}
|
||||
>
|
||||
{timestampLabel}
|
||||
</Text>
|
||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||
{showTimestamp ? timestampLabel : primaryLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -88,6 +88,7 @@ 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,
|
||||
@@ -513,7 +514,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
|
||||
return native instanceof HTMLElement ? native : null;
|
||||
}
|
||||
|
||||
interface PasteImagesEffectArgs {
|
||||
interface PasteEffectArgs {
|
||||
textarea: HTMLElement | null;
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
@@ -522,7 +523,7 @@ interface PasteImagesEffectArgs {
|
||||
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
|
||||
}
|
||||
|
||||
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
function usePasteEffect(args: PasteEffectArgs): void {
|
||||
const {
|
||||
textarea,
|
||||
isConnected,
|
||||
@@ -533,16 +534,20 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
} = args;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !onAddImages) return;
|
||||
if (!isWeb) return;
|
||||
|
||||
if (!textarea) return;
|
||||
|
||||
let disposed = false;
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
|
||||
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
|
||||
|
||||
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
|
||||
if (imageFiles.length === 0) return;
|
||||
if (imageFiles.length === 0) {
|
||||
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !onAddImages) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1811,7 +1816,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
handleAlternateSendAction();
|
||||
}, [alternateSendAction, handleAlternateSendAction]);
|
||||
|
||||
usePasteImagesEffect({
|
||||
usePasteEffect({
|
||||
textarea: webTextArea,
|
||||
isConnected,
|
||||
disabled,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Native inputs retain their platform clipboard handling. */
|
||||
export function pasteLinkedClipboardText(
|
||||
_input: HTMLElement,
|
||||
_clipboard: Pick<DataTransfer, "getData"> | null,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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,8 +41,11 @@ export function useReplicaQuery<
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input));
|
||||
>(
|
||||
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input), queryClient);
|
||||
}
|
||||
|
||||
export function useFetchQuery<
|
||||
|
||||
@@ -1179,6 +1179,14 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "تعذر تحميل عرض الشريط الجانبي.",
|
||||
useDefault: "استخدام العرض الافتراضي",
|
||||
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
|
||||
agentUnavailable: "لم يعد الوكيل متاحًا",
|
||||
terminalUnavailable: "لم تعد الطرفية متاحة",
|
||||
projectUnavailable: "لم يعد المشروع متاحًا",
|
||||
},
|
||||
statusGroupAccessibility: "مجموعة {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "تحتاج إدخالاً",
|
||||
@@ -2607,6 +2615,12 @@ export const ar: TranslationResources = {
|
||||
description: "عرض مخطط للتنقل بين المطالبات",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "عرض الشريط الجانبي",
|
||||
label: "العرض",
|
||||
default: "العرض الافتراضي",
|
||||
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
|
||||
},
|
||||
header: {
|
||||
title: "الرأس",
|
||||
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
|
||||
@@ -1187,6 +1187,14 @@ 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",
|
||||
@@ -2698,6 +2706,12 @@ 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,6 +1215,14 @@ 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",
|
||||
@@ -2627,6 +2635,13 @@ 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,6 +1208,14 @@ 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",
|
||||
@@ -2628,6 +2636,13 @@ 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,6 +1192,14 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "サイドバーのビューを読み込めませんでした。",
|
||||
useDefault: "デフォルトのビューを使用",
|
||||
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
|
||||
agentUnavailable: "このエージェントは利用できなくなりました",
|
||||
terminalUnavailable: "このターミナルは利用できなくなりました",
|
||||
projectUnavailable: "このプロジェクトは利用できなくなりました",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} グループ",
|
||||
statusBucket: {
|
||||
needsInput: "入力待ち",
|
||||
@@ -2594,6 +2602,13 @@ export const ja: TranslationResources = {
|
||||
description: "プロンプト間を移動するためのアウトラインを表示します",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "サイドバーのビュー",
|
||||
label: "ビュー",
|
||||
default: "デフォルトのビュー",
|
||||
unavailable:
|
||||
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
|
||||
},
|
||||
header: {
|
||||
title: "ヘッダー",
|
||||
description: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
|
||||
@@ -1186,6 +1186,14 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "사이드바 보기를 불러오지 못했습니다.",
|
||||
useDefault: "기본 보기 사용",
|
||||
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
|
||||
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
|
||||
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
|
||||
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} 그룹",
|
||||
statusBucket: {
|
||||
needsInput: "입력 필요",
|
||||
@@ -2585,6 +2593,13 @@ export const ko: TranslationResources = {
|
||||
description: "프롬프트 사이를 이동하기 위한 개요 표시",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "사이드바 보기",
|
||||
label: "보기",
|
||||
default: "기본 보기",
|
||||
unavailable:
|
||||
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
|
||||
},
|
||||
header: {
|
||||
title: "헤더",
|
||||
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
|
||||
@@ -1205,6 +1205,14 @@ 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",
|
||||
@@ -2609,6 +2617,13 @@ 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,6 +1196,14 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Не удалось загрузить вид боковой панели.",
|
||||
useDefault: "Использовать стандартный вид",
|
||||
readFailed: "Не удалось загрузить настройку вида боковой панели.",
|
||||
agentUnavailable: "Агент больше недоступен",
|
||||
terminalUnavailable: "Терминал больше недоступен",
|
||||
projectUnavailable: "Проект больше недоступен",
|
||||
},
|
||||
statusGroupAccessibility: "Группа «{{label}}»",
|
||||
statusBucket: {
|
||||
needsInput: "Ожидает ввода",
|
||||
@@ -2615,6 +2623,12 @@ export const ru: TranslationResources = {
|
||||
description: "Показывать оглавление для перехода между запросами",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Вид боковой панели",
|
||||
label: "Вид",
|
||||
default: "Стандартный вид",
|
||||
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
|
||||
},
|
||||
header: {
|
||||
title: "Верх",
|
||||
description:
|
||||
|
||||
@@ -1171,6 +1171,14 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "无法加载侧边栏视图。",
|
||||
useDefault: "使用默认视图",
|
||||
readFailed: "无法加载侧边栏视图偏好设置。",
|
||||
agentUnavailable: "此智能体已不可用",
|
||||
terminalUnavailable: "此终端已不可用",
|
||||
projectUnavailable: "此项目已不可用",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} 分组",
|
||||
statusBucket: {
|
||||
needsInput: "需要输入",
|
||||
@@ -2544,6 +2552,12 @@ export const zhCN: TranslationResources = {
|
||||
description: "显示用于在提示词之间跳转的大纲",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "侧边栏视图",
|
||||
label: "视图",
|
||||
default: "默认视图",
|
||||
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
|
||||
},
|
||||
header: {
|
||||
title: "顶部",
|
||||
description: "选择侧边栏顶部显示的项目及其顺序",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import { callPluginRpc } from "@getpaseo/plugin/client/host";
|
||||
import type {
|
||||
PluginAgentCommandContext,
|
||||
@@ -32,7 +33,7 @@ export function createPluginCapabilities(
|
||||
navigation.openSurface(plugin.id, screenId, params);
|
||||
}
|
||||
return {
|
||||
paseo: plugin.paseo,
|
||||
paseo: getPluginClientApi(plugin).paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
|
||||
openSettings(screenId) {
|
||||
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
|
||||
|
||||
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import appPackage from "../../package.json";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
@@ -139,7 +140,7 @@ function harness() {
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
paseo: getPluginClientApi(installation).paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected runtime RPC");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createPlayAudio, type AudioEngine } from "@/audio";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
|
||||
import {
|
||||
@@ -27,7 +26,7 @@ export function createPluginClientRuntime(
|
||||
...capabilities,
|
||||
i18n: installation.i18n,
|
||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||
hosts: getPluginClientApi(installation).hosts,
|
||||
addComposerPill(contribution) {
|
||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
@@ -0,0 +1,53 @@
|
||||
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" },
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createEntityRefs } from "@getpaseo/plugin/client/host";
|
||||
|
||||
export const entityRefs = createEntityRefs();
|
||||
@@ -1,66 +1,20 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { fr } from "@/i18n/resources/fr";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
createPluginAgentSnapshot,
|
||||
createPluginWorkspaceSnapshot,
|
||||
createPluginProjectSnapshot,
|
||||
} from "./snapshots";
|
||||
|
||||
import { workspace, agent } from "./fixtures";
|
||||
describe("plugin context snapshots", () => {
|
||||
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
|
||||
const snapshot = createPluginWorkspaceSnapshot(workspace);
|
||||
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
|
||||
const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1");
|
||||
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot);
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
expect(snapshot).toMatchObject({
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Paseo",
|
||||
@@ -90,7 +44,7 @@ describe("plugin context snapshots", () => {
|
||||
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
|
||||
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
expect(snapshot).toMatchObject({
|
||||
id: "agent-1",
|
||||
workspaceId: "workspace-1",
|
||||
provider: "codex",
|
||||
@@ -119,3 +73,82 @@ 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,16 +1,57 @@
|
||||
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
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";
|
||||
|
||||
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
|
||||
import { entityRefs } from "./refs";
|
||||
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
|
||||
|
||||
interface CachedWorkspace {
|
||||
language: string;
|
||||
snapshot: PluginWorkspaceSnapshot;
|
||||
}
|
||||
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, Map<string, CachedWorkspace>>();
|
||||
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 cached = workspaceSnapshots.get(workspace);
|
||||
if (cached) return cached;
|
||||
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 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,
|
||||
@@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot(
|
||||
name: workspace.name,
|
||||
title: workspace.title ?? null,
|
||||
status: workspace.status,
|
||||
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
|
||||
statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt),
|
||||
archivingAt: workspace.archivingAt,
|
||||
diffStat,
|
||||
});
|
||||
workspaceSnapshots.set(workspace, snapshot);
|
||||
const byHost = workspaceSnapshots.get(workspace) ?? new Map<string, CachedWorkspace>();
|
||||
byHost.set(hostId, { language: i18n.language, snapshot });
|
||||
workspaceSnapshots.set(workspace, byHost);
|
||||
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,
|
||||
@@ -55,3 +103,36 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
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,21 +1,248 @@
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
|
||||
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 { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
|
||||
import { connectedPluginHosts } from "./connected-hosts";
|
||||
import { pluginTerminals } from "./terminals";
|
||||
import { entityRefs } from "./refs";
|
||||
import {
|
||||
createPluginAgentSnapshot,
|
||||
createPluginProjectSnapshot,
|
||||
createPluginWorkspaceSnapshot,
|
||||
} from "./snapshots";
|
||||
|
||||
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
|
||||
return {
|
||||
subscribe: useSessionStore.subscribe,
|
||||
getWorkspace(workspaceId) {
|
||||
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
|
||||
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
|
||||
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),
|
||||
},
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
};
|
||||
},
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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,7 +13,20 @@ 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",
|
||||
@@ -30,6 +43,10 @@ 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",
|
||||
@@ -82,6 +99,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [{ id: "main", title: "Main", Component: () => null }],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
@@ -131,6 +149,15 @@ 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,6 +55,29 @@ 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,6 +129,7 @@ export function runPluginClientBundle(
|
||||
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
|
||||
surfaces: [],
|
||||
settingsScreens: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
@@ -141,6 +142,7 @@ 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(),
|
||||
@@ -270,6 +272,18 @@ 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);
|
||||
},
|
||||
@@ -518,7 +532,8 @@ export function runPluginClientBundle(
|
||||
if (name === "@getpaseo/plugin/client")
|
||||
return {
|
||||
...pluginClientRuntime,
|
||||
useSettings,
|
||||
useSettings: (definition: import("@getpaseo/plugin").SettingsDefinition) =>
|
||||
useSettings(definition, id),
|
||||
openExternalUrl,
|
||||
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
|
||||
useHosts: () =>
|
||||
@@ -583,6 +598,7 @@ export function runPluginClientBundle(
|
||||
cleanup,
|
||||
surfaces: collector.surfaces,
|
||||
settingsScreens: collector.settingsScreens,
|
||||
sidebarViews: collector.sidebarViews,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
filePanels: collector.filePanels,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -24,7 +25,13 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
paseo: { dispose: async () => undefined } as PaseoApi,
|
||||
paseo: createPaseoApi(
|
||||
new DaemonClient({
|
||||
url: "ws://127.0.0.1:1",
|
||||
clientId: "installation-provider",
|
||||
reconnect: { enabled: false },
|
||||
}),
|
||||
),
|
||||
invoke: async (method) => {
|
||||
invoked.push(method);
|
||||
return { state: "green" };
|
||||
@@ -36,6 +43,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
@@ -75,4 +83,6 @@ 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,3 +1,4 @@
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
PaseoApiProvider,
|
||||
@@ -17,7 +18,7 @@ export function PluginInstallationProvider({
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PaseoApiProvider paseo={plugin.paseo}>
|
||||
<PaseoApiProvider paseo={getPluginClientApi(plugin).paseo}>
|
||||
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
|
||||
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
|
||||
</PluginRpcProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import { filePanelTitle } from "@/file-panels/choice/presentation";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
@@ -50,7 +51,7 @@ function registry(
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
paseo: getPluginClientApi(installation).paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected plugin RPC");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginClientApi } from "./client-state/api";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -25,7 +26,7 @@ function registry(version: string) {
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
paseo: getPluginClientApi(installation).paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("No RPC in this plugin");
|
||||
|
||||
@@ -122,6 +122,7 @@ export class PluginRegistry {
|
||||
cleanup: () => undefined,
|
||||
surfaces: [],
|
||||
settingsScreens: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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" });
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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,3 +1,6 @@
|
||||
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";
|
||||
@@ -8,6 +11,8 @@ 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);
|
||||
@@ -15,34 +20,49 @@ 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 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" });
|
||||
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,
|
||||
},
|
||||
});
|
||||
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,6 +31,7 @@ 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 }],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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()];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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 },
|
||||
}));
|
||||
@@ -0,0 +1,173 @@
|
||||
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";
|
||||
@@ -0,0 +1,58 @@
|
||||
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 } }));
|
||||
@@ -0,0 +1,47 @@
|
||||
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,6 +49,7 @@ function installation(
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: sidebarItems.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -24,6 +24,7 @@ function plugin(input: {
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -9,7 +9,8 @@ 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", () => ({
|
||||
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
|
||||
useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient),
|
||||
useHosts: () => [{ serverId: "host-1", label: "Local" }],
|
||||
}));
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution as SDKPluginComposerPillContribution,
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
PluginScreenContribution as SDKPluginScreenContribution,
|
||||
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
|
||||
@@ -48,6 +49,7 @@ 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,6 +21,7 @@ function installed(): InstalledPlugin {
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -20,7 +20,8 @@ const runtime = vi.hoisted(() => ({
|
||||
client: null as DaemonClient | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
|
||||
useHostRuntimeClient: () => runtime.client,
|
||||
useHostRuntimeIsConnected: () => runtime.connected,
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -183,6 +184,7 @@ function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElemen
|
||||
export function SidebarNavSection(): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<SidebarViewPicker />
|
||||
<SidebarItemsCard section="header" />
|
||||
<SidebarItemsCard section="footer" />
|
||||
</>
|
||||
|
||||
@@ -74,7 +74,8 @@ describe("terminal file paste queue", () => {
|
||||
|
||||
firstUpload.resolve("/host/uploads/a.png");
|
||||
await settle();
|
||||
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]);
|
||||
expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' ");
|
||||
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
|
||||
});
|
||||
|
||||
@@ -85,7 +86,7 @@ describe("terminal file paste queue", () => {
|
||||
queue.add(Promise.resolve(file("shot.png")), host);
|
||||
await settle();
|
||||
|
||||
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
|
||||
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']);
|
||||
});
|
||||
|
||||
it("uploads under a file name that needs no shell quoting", async () => {
|
||||
@@ -120,7 +121,7 @@ describe("terminal file paste queue", () => {
|
||||
|
||||
expect(events.failures).toEqual(["disk full"]);
|
||||
expect(events.uploaded).toBe(1);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/b.png' "]);
|
||||
});
|
||||
|
||||
it("reports unreadable and oversized files without uploading them", async () => {
|
||||
@@ -166,7 +167,7 @@ describe("terminal file paste queue", () => {
|
||||
queue.add(Promise.resolve(file("next.png")), host);
|
||||
await settle();
|
||||
|
||||
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/next.png' "]);
|
||||
expect(events.uploading).toEqual([true, false, true, false]);
|
||||
expect(queue.cancel()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ export class TerminalFilePasteQueue {
|
||||
this.setUploading(true);
|
||||
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
|
||||
if (isCurrent()) {
|
||||
target.paste(prepareTerminalPath(path));
|
||||
target.paste(`${prepareTerminalPath(path)} `);
|
||||
this.events.onUploaded();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
declare module "turndown-plugin-gfm" {
|
||||
import type TurndownService from "turndown";
|
||||
|
||||
export const tables: TurndownService.Plugin;
|
||||
export const gfm: TurndownService.Plugin;
|
||||
}
|
||||
|
||||
@@ -6578,10 +6578,16 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
@@ -190,6 +191,8 @@ 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;
|
||||
@@ -899,6 +902,14 @@ 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) => {
|
||||
@@ -1288,3 +1299,28 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { build } from "esbuild";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export async function buildFillFixture() {
|
||||
const { outputFiles } = await build({
|
||||
stdin: {
|
||||
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
|
||||
contents: `
|
||||
import { createElement as e, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
function Form() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [submitted, setSubmitted] = useState(null);
|
||||
return e('form', { onSubmit: ev => {
|
||||
ev.preventDefault(); setSubmitted({ title, notes });
|
||||
} },
|
||||
e('input', { 'aria-label': 'React title', value: title,
|
||||
onChange: ev => setTitle(ev.target.value) }),
|
||||
e('textarea', { 'aria-label': 'React notes', value: notes,
|
||||
onChange: ev => setNotes(ev.target.value) }),
|
||||
e('p', null, 'React state: ' + JSON.stringify({ title, notes })),
|
||||
e('button', { type: 'submit' }, 'Submit'),
|
||||
e('p', null, 'Submitted: ' + JSON.stringify(submitted)));
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(e(Form));
|
||||
document.getElementById('plain').addEventListener('input', ev => {
|
||||
document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value;
|
||||
});
|
||||
`,
|
||||
},
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
});
|
||||
return `<!doctype html><title>React fill fixture</title><div id="root"></div>
|
||||
<input aria-label="Plain input" id="plain"><p id="plain-state"></p>
|
||||
<div role="textbox" aria-label="Editable text" contenteditable="true"></div>
|
||||
<script>${outputFiles[0].text}</script>`;
|
||||
}
|
||||
|
||||
async function findRef(call, browserId, role, name) {
|
||||
const { snapshot } = await call("browser_snapshot", { browserId });
|
||||
const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`));
|
||||
const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1];
|
||||
expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy();
|
||||
return ref;
|
||||
}
|
||||
|
||||
async function fillField(call, browserId, name, value) {
|
||||
const ref = await findRef(call, browserId, "textbox", name);
|
||||
await call("browser_fill", { browserId, ref, value });
|
||||
}
|
||||
|
||||
async function submitForm(call, browserId) {
|
||||
const ref = await findRef(call, browserId, "button", "Submit");
|
||||
await call("browser_click", { browserId, ref });
|
||||
}
|
||||
|
||||
async function expectPageText(call, browserId, text) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const result = await call("browser_evaluate", {
|
||||
browserId,
|
||||
function: "() => document.body.innerText",
|
||||
});
|
||||
return JSON.parse(result.resultJson);
|
||||
})
|
||||
.toContain(text);
|
||||
}
|
||||
|
||||
export async function verifyReactFormFill(call, targetUrl) {
|
||||
const { browserId } = await call("browser_new_tab", {
|
||||
url: new URL("/react-fill", targetUrl).href,
|
||||
});
|
||||
try {
|
||||
await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 });
|
||||
await fillField(call, browserId, "Plain input", "plain value");
|
||||
await expectPageText(call, browserId, "Plain state: plain value");
|
||||
await fillField(call, browserId, "Editable text", "editable value");
|
||||
await expectPageText(call, browserId, "editable value");
|
||||
await fillField(call, browserId, "React title", "Hello from browser_fill");
|
||||
await fillField(call, browserId, "React notes", "First line\nSecond line");
|
||||
await expectPageText(
|
||||
call,
|
||||
browserId,
|
||||
'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
|
||||
);
|
||||
await submitForm(call, browserId);
|
||||
await expectPageText(
|
||||
call,
|
||||
browserId,
|
||||
'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
|
||||
);
|
||||
await fillField(call, browserId, "React title", "replacement");
|
||||
await fillField(call, browserId, "React notes", "");
|
||||
await submitForm(call, browserId);
|
||||
await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}');
|
||||
await fillField(call, browserId, "React title", "");
|
||||
await submitForm(call, browserId);
|
||||
await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}');
|
||||
} finally {
|
||||
await call("browser_close_tab", { browserId });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
|
||||
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
||||
import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs";
|
||||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||||
|
||||
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
|
||||
@@ -213,8 +214,24 @@ async function settleDesktopStartup(page) {
|
||||
}
|
||||
}
|
||||
|
||||
const CSP_PAGE_PATH = "/csp-no-eval";
|
||||
|
||||
async function startTargetPage() {
|
||||
const server = createServer((_request, response) => {
|
||||
const fillFixture = await buildFillFixture();
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/react-fill") {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(fillFixture);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(`<!doctype html>
|
||||
<html>
|
||||
@@ -246,6 +263,67 @@ 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(
|
||||
@@ -290,6 +368,42 @@ 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`),
|
||||
@@ -675,6 +789,9 @@ async function runRegression({
|
||||
document.body.appendChild(overlayRoot);
|
||||
});
|
||||
|
||||
await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl);
|
||||
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");
|
||||
@@ -1020,6 +1137,7 @@ 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) => {
|
||||
@@ -1106,6 +1224,7 @@ async function runRegression({
|
||||
snapshot: "passed",
|
||||
click: "passed",
|
||||
localPageSelectors: "passed",
|
||||
evaluateWithoutUnsafeEval: "passed",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { createContext, runInContext } from "node:vm";
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
@@ -434,6 +435,54 @@ 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",
|
||||
@@ -1406,6 +1455,72 @@ 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" };
|
||||
@@ -1558,6 +1673,99 @@ 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,4 +1,5 @@
|
||||
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
|
||||
import { Script } from "node:vm";
|
||||
|
||||
import type {
|
||||
BrowserAutomationCommand,
|
||||
@@ -849,6 +850,11 @@ 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(
|
||||
@@ -1550,14 +1556,36 @@ 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 () => {
|
||||
return String.raw`(async (readFunction) => {
|
||||
const __PASEO_BROWSER_EVALUATE__ = true;
|
||||
try {
|
||||
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
|
||||
const userFunction = readFunction();
|
||||
if (typeof userFunction !== 'function') {
|
||||
throw new Error('browser_evaluate input must evaluate to a function.');
|
||||
}
|
||||
@@ -1582,7 +1610,7 @@ function buildEvaluateScript(
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
})()`;
|
||||
})(() => (${functionSource}))`;
|
||||
}
|
||||
|
||||
async function focusKeypressTarget(
|
||||
@@ -1628,6 +1656,8 @@ 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,
|
||||
|
||||
@@ -316,7 +316,16 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
|
||||
element.focus();
|
||||
const nextValue = ${JSON.stringify(value)};
|
||||
if ('value' in element) {
|
||||
element.value = nextValue;
|
||||
const view = element.ownerDocument.defaultView;
|
||||
let prototype = null;
|
||||
if (element.tagName === 'INPUT') {
|
||||
prototype = view.HTMLInputElement.prototype;
|
||||
} else if (element.tagName === 'TEXTAREA') {
|
||||
prototype = view.HTMLTextAreaElement.prototype;
|
||||
}
|
||||
// Bypass instance setters so framework value trackers observe the input event as a change.
|
||||
if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue);
|
||||
else element.value = nextValue;
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { ok: true };
|
||||
|
||||
@@ -388,3 +388,45 @@ 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,12 +1,30 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
||||
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js";
|
||||
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 { shallowEqual } from "./shallow.js";
|
||||
|
||||
export interface PluginClientStateSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getWorkspace(id: string): PluginWorkspaceSnapshot | null;
|
||||
getAgent(id: string): PluginAgentSnapshot | null;
|
||||
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>;
|
||||
}
|
||||
|
||||
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
|
||||
@@ -15,44 +33,113 @@ export function usePluginClientStateSource(): PluginClientStateSource | null {
|
||||
return useContext(PluginClientStateContext);
|
||||
}
|
||||
|
||||
function usePluginEntity<Entity, Selection>(input: {
|
||||
id: string;
|
||||
read(source: PluginClientStateSource, id: string): Entity | null;
|
||||
selector(entity: Entity): Selection;
|
||||
}): Selection | null {
|
||||
/** 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) {
|
||||
const source = usePluginClientStateSource();
|
||||
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,
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspace<Selection>(
|
||||
workspaceId: string,
|
||||
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
|
||||
): Selection | null {
|
||||
return usePluginEntity({
|
||||
id: workspaceId,
|
||||
read: (source, id) => source.getWorkspace(id),
|
||||
selector,
|
||||
});
|
||||
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 useAgent<Selection>(
|
||||
agentId: string,
|
||||
selector: (agent: PluginAgentSnapshot) => Selection,
|
||||
): Selection | null {
|
||||
return usePluginEntity({
|
||||
id: agentId,
|
||||
read: (source, id) => source.getAgent(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 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,6 +1,7 @@
|
||||
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
|
||||
import type { PluginText } from "../text.js";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { PluginPaseoApi } from "./reference-api.js";
|
||||
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";
|
||||
@@ -96,6 +97,24 @@ 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.
|
||||
@@ -185,6 +204,7 @@ 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;
|
||||
@@ -308,7 +328,7 @@ export interface PluginTimelineRendererContribution<Schema extends ZodType = Zod
|
||||
}
|
||||
|
||||
export interface PluginCommandCapabilities {
|
||||
paseo: PaseoApi;
|
||||
paseo: PluginPaseoApi;
|
||||
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
input: PluginWireInput<ZodInput<InputSchema>>,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
|
||||
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
||||
export {
|
||||
PluginClientStateProvider,
|
||||
usePaseoSelector,
|
||||
type PluginClientStateSource,
|
||||
type PluginClientSnapshot,
|
||||
} from "./client-state.js";
|
||||
export {
|
||||
usePluginRuntimeContextBridge,
|
||||
type PluginRuntimeContextBridge,
|
||||
@@ -31,3 +36,7 @@ export {
|
||||
isPluginMessage,
|
||||
} from "./translations.js";
|
||||
export type { PluginLocaleSource } from "./translations.js";
|
||||
|
||||
export { createEntityRefs } from "./refs.js";
|
||||
|
||||
export { createPluginReferenceApi } from "./reference-api.js";
|
||||
|
||||
@@ -15,6 +15,8 @@ export type {
|
||||
PluginOpenScreenInput,
|
||||
PluginScreenLocation,
|
||||
PluginSidebarItemProps,
|
||||
PluginSidebarViewProps,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
@@ -70,7 +72,16 @@ export {
|
||||
useFileUrl,
|
||||
} from "./file.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
export {
|
||||
useAgent,
|
||||
useWorkspace,
|
||||
useProject,
|
||||
useTerminal,
|
||||
useWorkspaces,
|
||||
useAgents,
|
||||
useProjects,
|
||||
useTerminals,
|
||||
} from "./client-state.js";
|
||||
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
|
||||
export type {
|
||||
PluginSubscribeOptions,
|
||||
@@ -102,3 +113,8 @@ 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,17 +1,23 @@
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { PluginPaseoApi } from "./reference-api.js";
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const PaseoApiContext = createContext<PaseoApi | null>(null);
|
||||
const PaseoApiContext = createContext<PluginPaseoApi | null>(null);
|
||||
|
||||
export function usePaseoContextValue(): PaseoApi | null {
|
||||
export function usePaseoContextValue(): PluginPaseoApi | null {
|
||||
return useContext(PaseoApiContext);
|
||||
}
|
||||
|
||||
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
|
||||
export function PaseoApiProvider({
|
||||
children,
|
||||
paseo,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
paseo: PluginPaseoApi;
|
||||
}) {
|
||||
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePaseo(): PaseoApi {
|
||||
export function usePaseo(): PluginPaseoApi {
|
||||
const paseo = usePaseoContextValue();
|
||||
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
|
||||
return paseo;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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] }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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,3 +1,4 @@
|
||||
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";
|
||||
@@ -19,7 +20,22 @@ 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;
|
||||
@@ -29,6 +45,7 @@ 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;
|
||||
@@ -36,6 +53,10 @@ 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;
|
||||
@@ -96,3 +117,22 @@ 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,6 +3,9 @@ export type {
|
||||
PluginTheme,
|
||||
PluginWorkspaceSnapshot,
|
||||
PluginAgentSnapshot,
|
||||
PluginProjectSnapshot,
|
||||
PluginTerminalSnapshot,
|
||||
WorkspaceIndicator,
|
||||
PluginThemeColors,
|
||||
PluginThemeContribution,
|
||||
PluginAttachmentSourceContribution,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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";
|
||||
scope: "host" | "device";
|
||||
version: number;
|
||||
schema: Schema;
|
||||
/** Convert a previous stored version to the current schema. Runs on the host. */
|
||||
/** Convert a previous stored version to the current schema. Runs in the selected scope. */
|
||||
migrate?: (values: unknown, fromVersion: number) => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ 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") throw new Error("Only host-scoped settings are supported");
|
||||
if (definition.scope !== "host" && definition.scope !== "device")
|
||||
throw new Error("Invalid settings scope");
|
||||
if (!Number.isSafeInteger(definition.version) || definition.version < 1)
|
||||
throw new Error("Invalid settings version");
|
||||
return definition;
|
||||
|
||||
@@ -571,3 +571,43 @@ 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(),
|
||||
cwd: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UnsubscribeTerminalsRequestSchema = z.object({
|
||||
type: z.literal("unsubscribe_terminals_request"),
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -3770,6 +3770,7 @@ 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.
|
||||
@@ -6684,6 +6685,8 @@ 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(),
|
||||
@@ -6747,8 +6750,8 @@ export const TerminalsChangedSchema = z.object({
|
||||
subscriptionId: z.string().optional(),
|
||||
requestId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
cwd: z.string(),
|
||||
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
|
||||
cwd: z.string().optional(),
|
||||
terminals: z.array(TerminalInfoSchema.partial({ cwd: true })),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// 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, workspaceId: string | undefined): string {
|
||||
export function terminalSubscriptionKey(
|
||||
cwd: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
): string {
|
||||
if (cwd === undefined)
|
||||
return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`;
|
||||
return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`;
|
||||
}
|
||||
|
||||
@@ -700,6 +700,71 @@ 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();
|
||||
@@ -4699,6 +4764,51 @@ 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");
|
||||
@@ -5370,6 +5480,9 @@ 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 } };
|
||||
}
|
||||
@@ -5452,6 +5565,63 @@ 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,6 +1679,7 @@ export class AgentManager {
|
||||
cwd: input.cwd,
|
||||
},
|
||||
resolvedAgentId,
|
||||
{ resolveDefaultModel: false },
|
||||
);
|
||||
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
|
||||
const launchContext = await this.buildLaunchContext(
|
||||
@@ -1705,8 +1706,13 @@ 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),
|
||||
stripInternalPaseoMcpServer({
|
||||
...imported.config,
|
||||
model: runtimeInfo.model ?? imported.config.model,
|
||||
}),
|
||||
{ resolveDefaultModel: false },
|
||||
);
|
||||
const timelineRows = buildImportedTimelineRows(imported.timeline);
|
||||
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
|
||||
@@ -2328,14 +2334,16 @@ export class AgentManager {
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
|
||||
if (agent.session.setModel) {
|
||||
await agent.session.setModel(normalizedModelId);
|
||||
}
|
||||
const selection = await agent.session.setModel?.(normalizedModelId);
|
||||
await this.drainSessionEvents(agentId);
|
||||
|
||||
agent.config.model = normalizedModelId ?? undefined;
|
||||
// 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;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
|
||||
}
|
||||
this.refreshSessionPersistence(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -5723,11 +5731,12 @@ export class AgentManager {
|
||||
private async prepareSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
|
||||
options: NormalizeConfigOptions = {},
|
||||
): 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,7 +720,9 @@ export interface AgentSession {
|
||||
/** Release live runtime resources without archiving or deleting the durable native session. */
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
/** 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 }>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||
|
||||
@@ -1400,9 +1400,10 @@ class PluginAgentSession implements AgentSession {
|
||||
}));
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
async setModel(modelId: string | null): Promise<{ model: string | null }> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ model: modelId });
|
||||
return { model: this.bridge.config.model ?? null };
|
||||
}
|
||||
|
||||
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